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.
Files changed (52) hide show
  1. package/.codex-plugin/plugin.json +23 -0
  2. package/.mcp.json +8 -0
  3. package/LICENSE +21 -0
  4. package/README.md +110 -0
  5. package/data/companies.json +2550 -0
  6. package/docs/job-seeker-quickstart.md +109 -0
  7. package/package.json +42 -0
  8. package/skills/openings/SKILL.md +28 -0
  9. package/src/artifact-path.ts +48 -0
  10. package/src/atomic-file.ts +13 -0
  11. package/src/candidate-profile.ts +273 -0
  12. package/src/career-tracing.ts +181 -0
  13. package/src/catalog.ts +382 -0
  14. package/src/cli.ts +601 -0
  15. package/src/common-crawl-discovery.ts +137 -0
  16. package/src/company-seeds.ts +43 -0
  17. package/src/country-coverage.ts +203 -0
  18. package/src/crawl-reporting.ts +56 -0
  19. package/src/crawler.ts +146 -0
  20. package/src/enrichment-registry.ts +137 -0
  21. package/src/file-lock.ts +85 -0
  22. package/src/index.ts +37 -0
  23. package/src/intent-validation.ts +28 -0
  24. package/src/job-coverage.ts +73 -0
  25. package/src/job-fit-analysis.ts +247 -0
  26. package/src/job-matching.ts +445 -0
  27. package/src/job-recommendations.ts +193 -0
  28. package/src/job-search-preparation.ts +116 -0
  29. package/src/jobposting-probe.ts +167 -0
  30. package/src/local-jobs.ts +113 -0
  31. package/src/locations.ts +180 -0
  32. package/src/mcp.ts +98 -0
  33. package/src/package-mcp.ts +8 -0
  34. package/src/recruitee-round.ts +114 -0
  35. package/src/report-meta.ts +17 -0
  36. package/src/requirement-vocabulary.ts +111 -0
  37. package/src/resume-optimization.ts +118 -0
  38. package/src/runtime.ts +51 -0
  39. package/src/safe-get.ts +88 -0
  40. package/src/safe-head.ts +79 -0
  41. package/src/screening-requirements.ts +99 -0
  42. package/src/selected-job-lookup.ts +14 -0
  43. package/src/snapshot-catalog.ts +21 -0
  44. package/src/snapshot-export.ts +66 -0
  45. package/src/snapshot-store.ts +31 -0
  46. package/src/source-discovery-pipeline.ts +26 -0
  47. package/src/source-discovery.ts +231 -0
  48. package/src/source-enrichment.ts +92 -0
  49. package/src/source-pipeline.ts +245 -0
  50. package/src/source-verification.ts +297 -0
  51. package/src/tools.ts +194 -0
  52. package/src/types.ts +136 -0
@@ -0,0 +1,137 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { atomicJson } from "./atomic-file.ts";
3
+ import { stampReport, type ReportMeta } from "./report-meta.ts";
4
+ import { mergeEnrichmentLeads, type EnrichmentLead } from "./enrichment-registry.ts";
5
+ import { resolveSource } from "./source-verification.ts";
6
+ import type { Ats } from "./types.ts";
7
+ import { assertArtifactFile } from "./artifact-path.ts";
8
+
9
+ type Fetch = (input: string | URL, init?: RequestInit) => Promise<Response>;
10
+ interface Options { country?: string; fetch?: Fetch; indexUrl?: string; registryPath?: string; provider?: Ats; indexRecordLimit?: number; sampleTokenLimit?: number; excludeTokens?: string[] }
11
+ interface Lead { sourceUrl: string; ats: string; token: string; discoveredFrom: { channel: "dataset"; reference: string } }
12
+ interface Collection { id?: unknown; "cdx-api"?: unknown }
13
+
14
+ export interface CommonCrawlDiscoveryReport extends ReportMeta {
15
+ country?: string;
16
+ provider?: Ats;
17
+ index: string;
18
+ indexRecordsExamined: number;
19
+ sampleTokenLimit?: number;
20
+ sampleShortfall: number;
21
+ urlsSeen: number;
22
+ sourcesFound: number;
23
+ alreadyKnown: number;
24
+ unresolved: number;
25
+ rejected: number;
26
+ truncated: boolean;
27
+ truncatedPatterns: string[];
28
+ candidatesPath: string;
29
+ reportPath: string;
30
+ registryPath?: string;
31
+ registryAdded: number;
32
+ registryBytes?: number;
33
+ registryLockHeldMs?: number;
34
+ availableLeads: Lead[];
35
+ leads: Lead[];
36
+ rejections: Array<{ value: string; reason: string }>;
37
+ }
38
+
39
+ const patterns = ["job-boards.greenhouse.io/*", "boards.greenhouse.io/*", "jobs.lever.co/*", "jobs.ashbyhq.com/*", "*.myworkdayjobs.com/*", "*.recruitee.com/*"];
40
+ const recordsPerPattern = 10_000;
41
+ const providerPatterns: Record<Ats, string[]> = {
42
+ greenhouse: patterns.slice(0, 2), lever: [patterns[2]!], ashby: [patterns[3]!], workday: [patterns[4]!], recruitee: [patterns[5]!],
43
+ };
44
+
45
+ export async function discoverCommonCrawlSources(candidatesPath: string, reportPath: string, options: Options = {}): Promise<CommonCrawlDiscoveryReport> {
46
+ if (options.provider === "recruitee") await assertArtifactFile(reportPath);
47
+ const country = options.country?.toUpperCase();
48
+ if (country && !/^[A-Z]{2}$/.test(country)) throw new Error("country must be a two-letter code");
49
+ const indexRecordLimit = options.indexRecordLimit ?? recordsPerPattern;
50
+ if (!Number.isInteger(indexRecordLimit) || indexRecordLimit < 1 || indexRecordLimit > recordsPerPattern) throw new Error(`indexRecordLimit must be an integer from 1 to ${recordsPerPattern}`);
51
+ const sampleTokenLimit = options.sampleTokenLimit;
52
+ if (sampleTokenLimit !== undefined && (!Number.isInteger(sampleTokenLimit) || sampleTokenLimit < 1 || sampleTokenLimit > indexRecordLimit)) throw new Error("sampleTokenLimit must be a positive integer no greater than indexRecordLimit");
53
+ if (options.provider === "recruitee" && (indexRecordLimit > 2_000 || sampleTokenLimit === undefined || sampleTokenLimit > 60 || options.registryPath)) throw new Error("Bounded Recruitee discovery requires at most 2,000 records, at most 60 sampled tokens, and report-only output");
54
+ const fetcher = options.fetch ?? globalThis.fetch;
55
+ const index = options.indexUrl ?? await latestIndex(fetcher);
56
+ const existing = await readExistingKeys(candidatesPath);
57
+ const found = new Map<string, ReturnType<typeof resolveSource>>();
58
+ const seenUrls = new Set<string>();
59
+ const rejections: Array<{ value: string; reason: string }> = [];
60
+ const truncatedPatterns: string[] = [];
61
+ let indexRecordsExamined = 0;
62
+
63
+ for (const pattern of options.provider ? providerPatterns[options.provider] : patterns) {
64
+ const query = new URL(index);
65
+ query.searchParams.set("url", pattern);
66
+ query.searchParams.set("output", "json");
67
+ query.searchParams.set("filter", "status:200");
68
+ query.searchParams.set("collapse", "urlkey");
69
+ query.searchParams.set("fl", "url");
70
+ query.searchParams.set("limit", String(indexRecordLimit));
71
+ const response = await fetcher(query);
72
+ if (!response.ok) throw new Error(`Common Crawl index returned HTTP ${response.status} for ${pattern}`);
73
+ const lines = (await response.text()).split(/\r?\n/).filter(Boolean).slice(0, indexRecordLimit);
74
+ indexRecordsExamined += lines.length;
75
+ if (lines.length >= indexRecordLimit) truncatedPatterns.push(pattern);
76
+ for (const line of lines) {
77
+ let value: unknown;
78
+ try { value = JSON.parse(line); } catch { rejections.push({ value: line, reason: "invalid_index_record" }); continue; }
79
+ if (!isRecord(value) || typeof value.url !== "string") { rejections.push({ value: line, reason: "missing_url" }); continue; }
80
+ seenUrls.add(value.url);
81
+ const source = resolveSource(value.url);
82
+ if (!source) { rejections.push({ value: value.url, reason: "unsupported_source" }); continue; }
83
+ found.set(`${source.ats}:${source.token.toLowerCase()}`, source);
84
+ }
85
+ }
86
+
87
+ const availableLeads: Lead[] = [];
88
+ const excludedTokens = new Set((options.excludeTokens ?? []).map((token) => token.toLowerCase()));
89
+ let alreadyKnown = 0;
90
+ for (const [key, source] of [...found].sort(([a], [b]) => a.localeCompare(b))) {
91
+ if (!source) continue;
92
+ if (existing.has(key)) { alreadyKnown++; continue; }
93
+ if (excludedTokens.has(source.token.toLowerCase())) continue;
94
+ availableLeads.push({
95
+ sourceUrl: source.canonicalSourceUrl, ats: source.ats, token: source.token,
96
+ discoveredFrom: { channel: "dataset", reference: index },
97
+ });
98
+ }
99
+ const leads = sampleTokenLimit === undefined ? availableLeads : availableLeads.slice(0, sampleTokenLimit);
100
+ const registryLeads: EnrichmentLead[] = leads.map((lead) => ({
101
+ sourceKey: `${lead.ats}:${lead.token.toLowerCase()}`, sourceUrl: lead.sourceUrl, ats: lead.ats as Ats, token: lead.token,
102
+ discoveredFrom: [lead.discoveredFrom], companyMatches: [], identityEvidence: [], attempts: [],
103
+ }));
104
+ const registry = options.registryPath ? await mergeEnrichmentLeads(options.registryPath, registryLeads) : { added: 0, bytes: undefined, lockHeldMs: undefined };
105
+ const report: CommonCrawlDiscoveryReport = stampReport("common-crawl-discovery:1", 1, {
106
+ country, provider: options.provider, index, indexRecordsExamined, sampleTokenLimit, sampleShortfall: sampleTokenLimit === undefined ? 0 : Math.max(0, sampleTokenLimit - leads.length), urlsSeen: seenUrls.size, sourcesFound: found.size, alreadyKnown, unresolved: leads.length, rejected: rejections.length,
107
+ truncated: truncatedPatterns.length > 0, truncatedPatterns,
108
+ candidatesPath, reportPath, registryPath: options.registryPath, registryAdded: registry.added, registryBytes: registry.bytes, registryLockHeldMs: registry.lockHeldMs, availableLeads, leads, rejections,
109
+ });
110
+ await atomicJson(reportPath, report);
111
+ return report;
112
+ }
113
+
114
+ async function latestIndex(fetcher: Fetch): Promise<string> {
115
+ const response = await fetcher("https://index.commoncrawl.org/collinfo.json");
116
+ if (!response.ok) throw new Error(`Common Crawl collection list returned HTTP ${response.status}`);
117
+ const value: unknown = await response.json();
118
+ if (!Array.isArray(value)) throw new Error("Common Crawl collection list is invalid");
119
+ const collection = value.find((entry): entry is Collection => isRecord(entry) && typeof entry["cdx-api"] === "string");
120
+ if (!collection || typeof collection["cdx-api"] !== "string") throw new Error("Common Crawl collection list contains no queryable index");
121
+ return collection["cdx-api"];
122
+ }
123
+
124
+ async function readExistingKeys(path: string): Promise<Set<string>> {
125
+ try {
126
+ const value: unknown = JSON.parse(await readFile(path, "utf8"));
127
+ if (!Array.isArray(value)) throw new Error("Candidate file must contain an array");
128
+ return new Set(value.flatMap((candidate) => isRecord(candidate) && typeof candidate.sourceUrl === "string" ? [resolveSource(candidate.sourceUrl)] : [])
129
+ .filter((source): source is NonNullable<ReturnType<typeof resolveSource>> => Boolean(source))
130
+ .map((source) => `${source.ats}:${source.token.toLowerCase()}`));
131
+ } catch (error) {
132
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return new Set();
133
+ throw error;
134
+ }
135
+ }
136
+
137
+ function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
@@ -0,0 +1,43 @@
1
+ import { atomicJson } from "./atomic-file.ts";
2
+ import { withFileLock } from "./file-lock.ts";
3
+
4
+ type Fetch = (input: string | URL, init?: RequestInit) => Promise<Response>;
5
+ interface Options { country: string; fetch?: Fetch }
6
+ interface CompanySeed { companyName: string; companyDomain: string }
7
+
8
+ export interface CompanySeedReport {
9
+ country: string;
10
+ matched: number;
11
+ written: number;
12
+ skipped: number;
13
+ outputPath: string;
14
+ }
15
+
16
+ export async function generateYcCompanySeeds(outputPath: string, options: Options): Promise<CompanySeedReport> {
17
+ const country = options.country.toUpperCase();
18
+ const countryName = new Intl.DisplayNames(["en"], { type: "region" }).of(country);
19
+ if (!/^[A-Z]{2}$/.test(country) || !countryName || countryName === country) throw new Error("country must be a valid two-letter code");
20
+ const response = await (options.fetch ?? globalThis.fetch)("https://yc-oss.github.io/api/companies/all.json");
21
+ if (!response.ok) throw new Error(`YC company API returned HTTP ${response.status}`);
22
+ const value: unknown = await response.json();
23
+ if (!Array.isArray(value) || !value.every(isRecord)) throw new Error("YC company API returned an invalid payload");
24
+ const pattern = new RegExp(`\\b${escapeRegExp(countryName)}\\b`, "i");
25
+ const matched = value.filter((company) => typeof company.all_locations === "string" && pattern.test(company.all_locations));
26
+ const seen = new Set<string>();
27
+ const seeds: CompanySeed[] = [];
28
+ for (const company of matched) {
29
+ if (typeof company.name !== "string" || !company.name.trim() || typeof company.website !== "string") continue;
30
+ let url: URL;
31
+ try { url = new URL(company.website); } catch { continue; }
32
+ if (url.protocol !== "https:" && url.protocol !== "http:") continue;
33
+ const companyDomain = url.hostname.toLowerCase().replace(/^www\./, "");
34
+ if (!/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(companyDomain) || seen.has(companyDomain)) continue;
35
+ seen.add(companyDomain);
36
+ seeds.push({ companyName: company.name.trim(), companyDomain });
37
+ }
38
+ await withFileLock(outputPath, () => atomicJson(outputPath, seeds), { operation: "write company seeds" });
39
+ return { country, matched: matched.length, written: seeds.length, skipped: matched.length - seeds.length, outputPath };
40
+ }
41
+
42
+ function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
43
+ function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
@@ -0,0 +1,203 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { atomicJson } from "./atomic-file.ts";
3
+ import { deriveLeadState, readEnrichmentRegistry, type EnrichmentState } from "./enrichment-registry.ts";
4
+ import { isEligibleForCountry } from "./locations.ts";
5
+ import { stampReport, type ReportMeta } from "./report-meta.ts";
6
+ import { resolveSource } from "./source-verification.ts";
7
+ import type { Ats, EligibilityConfidence, JobSnapshot, SourceCandidate, VerifiedCompany } from "./types.ts";
8
+ import { projectJobCoverage } from "./job-coverage.ts";
9
+
10
+ export interface CountryCoveragePaths {
11
+ catalog: string;
12
+ candidates: string;
13
+ registry: string;
14
+ snapshot: string;
15
+ output?: string;
16
+ }
17
+
18
+ export interface CountryCoverageReport extends ReportMeta {
19
+ country: string;
20
+ catalog: {
21
+ verifiedSources: number; countryCohortSources: number;
22
+ providers: Partial<Record<Ats, number>>; countryCohortProviders: Partial<Record<Ats, number>>;
23
+ };
24
+ snapshot: {
25
+ updatedAt: string; indexedSources: number; countryCohortIndexedSources: number; catalogCoveragePercent: number; orphanedSources: string[];
26
+ snapshotJobs: number; indexedJobs: number; indexedSourcesWithEligibleJobs: number; eligibleJobs: number; distinctEligibleEmployers: number;
27
+ indexedProviders: Partial<Record<Ats, number>>; countryCohortIndexedProviders: Partial<Record<Ats, number>>;
28
+ allIndexedJobsByConfidence: Record<EligibilityConfidence, number>; eligibleJobsByConfidence: Record<EligibilityConfidence, number>;
29
+ };
30
+ sourceHealth: { latestBatch: { selected: number; succeeded: number; failed: number; successPercent: number | null } };
31
+ freshness: {
32
+ referenceAt: string; fresh24Hours: number; age1To7Days: number; age8To14Days: number; olderThan14Days: number;
33
+ futureTimestamp: number; neverIndexedSources: string[]; oldestFetchedAt: string | null; newestFetchedAt: string | null;
34
+ sources: { fresh24Hours: string[]; age1To7Days: string[]; age8To14Days: string[]; olderThan14Days: string[]; futureTimestamp: string[] };
35
+ };
36
+ discovery: {
37
+ registryLeadsGlobal: number; registryStatesGlobal: Record<EnrichmentState, number>; verifiedRegistryLeadsGlobal: number; registryVerificationYieldPercentGlobal: number;
38
+ candidatesGlobal: number; promotedCandidatesGlobal: number; candidatePromotionPercentGlobal: number;
39
+ countryCohortCandidates: number; promotedCountryCohortCandidates: number; countryCohortCandidatePromotionPercent: number;
40
+ };
41
+ }
42
+
43
+ export async function generateCountryCoverageReport(paths: CountryCoveragePaths, options: { country: string; asOf?: Date }): Promise<CountryCoverageReport> {
44
+ const country = options.country.toUpperCase();
45
+ if (!/^[A-Z]{2}$/u.test(country)) throw new Error("Country coverage requires a two-letter country code");
46
+ const asOf = options.asOf ?? new Date();
47
+ if (!Number.isFinite(asOf.getTime())) throw new Error("Country coverage requires a valid reference time");
48
+ const [catalog, candidates, registry, snapshot] = await Promise.all([
49
+ readCatalog(paths.catalog), readCandidates(paths.candidates), readEnrichmentRegistry(paths.registry), readSnapshot(paths.snapshot),
50
+ ]);
51
+ const slugs = Object.keys(catalog).sort();
52
+ const countryCohortSlugs = slugs.filter((slug) => catalog[slug]!.cohorts?.includes(country));
53
+ const providers = countProviders(slugs.map((slug) => catalog[slug]!.ats));
54
+ const indexedSlugs = slugs.filter((slug) => snapshot.partitions[slug]);
55
+ const countryCohortIndexedSlugs = countryCohortSlugs.filter((slug) => snapshot.partitions[slug]);
56
+ const neverIndexedSources = slugs.filter((slug) => !snapshot.partitions[slug]);
57
+ const orphanedSources = Object.keys(snapshot.partitions).filter((slug) => !catalog[slug]).sort();
58
+ const indexedJobs = indexedSlugs.flatMap((slug) => snapshot.partitions[slug]!.jobs);
59
+ const eligibleJobs = indexedJobs.filter((job) => isEligibleForCountry(job, country));
60
+ const candidateCoverage = projectJobCoverage(slugs.map((slug) => ({ slug, ...catalog[slug]! })), snapshot, [country]).countries[0]!;
61
+ const allConfidence = confidenceCounts(indexedJobs);
62
+ const eligibleConfidence = confidenceCounts(eligibleJobs);
63
+ const states = { unresolved: 0, matched: 0, evidence_ready: 0, verified: 0, rejected: 0 } satisfies Record<EnrichmentState, number>;
64
+ for (const lead of registry.leads) states[deriveLeadState(lead)] += 1;
65
+ const promotedCandidates = candidates.filter((candidate) => candidateIsPromoted(candidate, catalog)).length;
66
+ const countryCandidates = candidates.filter((candidate) => candidate.cohorts?.includes(country));
67
+ const promotedCountryCandidates = countryCandidates.filter((candidate) => candidateIsPromoted(candidate, catalog)).length;
68
+ const selected = snapshot.lastCrawl.selected;
69
+ const report = stampReport("country-coverage:1", 1, {
70
+ country,
71
+ catalog: {
72
+ verifiedSources: slugs.length, countryCohortSources: countryCohortSlugs.length, providers,
73
+ countryCohortProviders: countProviders(countryCohortSlugs.map((slug) => catalog[slug]!.ats)),
74
+ },
75
+ snapshot: {
76
+ updatedAt: snapshot.updatedAt,
77
+ indexedSources: indexedSlugs.length,
78
+ countryCohortIndexedSources: countryCohortIndexedSlugs.length,
79
+ catalogCoveragePercent: percent(indexedSlugs.length, slugs.length),
80
+ orphanedSources,
81
+ snapshotJobs: Object.values(snapshot.partitions).reduce((sum, partition) => sum + partition.jobs.length, 0),
82
+ indexedJobs: indexedJobs.length,
83
+ indexedSourcesWithEligibleJobs: candidateCoverage.indexedSourcesWithEligibleJobs,
84
+ eligibleJobs: candidateCoverage.eligibleJobs,
85
+ distinctEligibleEmployers: candidateCoverage.distinctEligibleEmployers,
86
+ indexedProviders: countProviders(indexedSlugs.map((slug) => catalog[slug]!.ats)),
87
+ countryCohortIndexedProviders: countProviders(countryCohortIndexedSlugs.map((slug) => catalog[slug]!.ats)),
88
+ allIndexedJobsByConfidence: allConfidence,
89
+ eligibleJobsByConfidence: eligibleConfidence,
90
+ },
91
+ sourceHealth: { latestBatch: {
92
+ selected, succeeded: snapshot.lastCrawl.succeeded, failed: snapshot.lastCrawl.failed.length,
93
+ successPercent: selected > 0 ? percent(snapshot.lastCrawl.succeeded, selected) : null,
94
+ } },
95
+ freshness: freshness(indexedSlugs.map((slug) => [slug, snapshot.partitions[slug]!.fetchedAt] as const), neverIndexedSources, asOf),
96
+ discovery: {
97
+ registryLeadsGlobal: registry.leads.length, registryStatesGlobal: states, verifiedRegistryLeadsGlobal: states.verified,
98
+ registryVerificationYieldPercentGlobal: percent(states.verified, registry.leads.length),
99
+ candidatesGlobal: candidates.length, promotedCandidatesGlobal: promotedCandidates,
100
+ candidatePromotionPercentGlobal: percent(promotedCandidates, candidates.length),
101
+ countryCohortCandidates: countryCandidates.length, promotedCountryCohortCandidates: promotedCountryCandidates,
102
+ countryCohortCandidatePromotionPercent: percent(promotedCountryCandidates, countryCandidates.length),
103
+ },
104
+ }, asOf);
105
+ if (paths.output) await atomicJson(paths.output, report);
106
+ return report;
107
+ }
108
+
109
+ async function readCatalog(path: string): Promise<Record<string, Omit<VerifiedCompany, "slug">>> {
110
+ const value: unknown = JSON.parse(await readFile(path, "utf8"));
111
+ if (!isRecord(value)) throw new Error(`Invalid verified catalog: ${path}`);
112
+ for (const [slug, company] of Object.entries(value)) {
113
+ if (!isRecord(company) || !["greenhouse", "lever", "ashby", "workday", "recruitee"].includes(String(company.ats)) || typeof company.token !== "string"
114
+ || typeof company.companyDomain !== "string" || typeof company.sourceUrl !== "string"
115
+ || company.cohorts !== undefined && (!Array.isArray(company.cohorts) || !company.cohorts.every(validCountryCode))
116
+ || !validVerification(company.verification)) throw new Error(`Invalid verified catalog source: ${slug}`);
117
+ }
118
+ return value as Record<string, Omit<VerifiedCompany, "slug">>;
119
+ }
120
+
121
+ async function readCandidates(path: string): Promise<SourceCandidate[]> {
122
+ const value: unknown = JSON.parse(await readFile(path, "utf8"));
123
+ if (!Array.isArray(value) || !value.every((candidate) => isRecord(candidate) && typeof candidate.companyName === "string"
124
+ && typeof candidate.companyDomain === "string" && typeof candidate.sourceUrl === "string" && isRecord(candidate.discoveredFrom)
125
+ && (candidate.cohorts === undefined || Array.isArray(candidate.cohorts) && candidate.cohorts.every((country) => typeof country === "string")))) throw new Error(`Invalid source candidates: ${path}`);
126
+ return value as SourceCandidate[];
127
+ }
128
+
129
+ async function readSnapshot(path: string): Promise<JobSnapshot> {
130
+ const value: unknown = JSON.parse(await readFile(path, "utf8"));
131
+ if (!isRecord(value) || value.version !== 1 || !validDate(value.updatedAt) || !isRecord(value.partitions) || !isRecord(value.lastCrawl)
132
+ || !Object.values(value.partitions).every(validPartition) || !validLastCrawl(value.lastCrawl)) throw new Error(`Invalid job snapshot: ${path}`);
133
+ return value as unknown as JobSnapshot;
134
+ }
135
+
136
+ function candidateIsPromoted(candidate: SourceCandidate, catalog: Record<string, Omit<VerifiedCompany, "slug">>): boolean {
137
+ const source = resolveSource(candidate.sourceUrl);
138
+ if (!source) return false;
139
+ return Object.values(catalog).some((company) => company.ats === source.ats && company.token.toLowerCase() === source.token.toLowerCase()
140
+ && company.companyDomain.toLowerCase() === candidate.companyDomain.toLowerCase());
141
+ }
142
+
143
+ function countProviders(values: Ats[]): Partial<Record<Ats, number>> {
144
+ const counts: Partial<Record<Ats, number>> = {};
145
+ for (const provider of [...values].sort()) counts[provider] = (counts[provider] ?? 0) + 1;
146
+ return counts;
147
+ }
148
+
149
+ function freshness(values: ReadonlyArray<readonly [string, string]>, neverIndexedSources: string[], asOf: Date): CountryCoverageReport["freshness"] {
150
+ const result: CountryCoverageReport["freshness"] = {
151
+ referenceAt: asOf.toISOString(), fresh24Hours: 0, age1To7Days: 0, age8To14Days: 0, olderThan14Days: 0,
152
+ futureTimestamp: 0, neverIndexedSources, oldestFetchedAt: null, newestFetchedAt: null,
153
+ sources: { fresh24Hours: [], age1To7Days: [], age8To14Days: [], olderThan14Days: [], futureTimestamp: [] },
154
+ };
155
+ const valid: number[] = [];
156
+ for (const [slug, value] of values) {
157
+ const timestamp = Date.parse(value);
158
+ valid.push(timestamp);
159
+ const ageMs = asOf.getTime() - timestamp;
160
+ if (ageMs < 0) { result.futureTimestamp += 1; result.sources.futureTimestamp.push(slug); }
161
+ else if (ageMs <= 86_400_000) { result.fresh24Hours += 1; result.sources.fresh24Hours.push(slug); }
162
+ else if (ageMs <= 7 * 86_400_000) { result.age1To7Days += 1; result.sources.age1To7Days.push(slug); }
163
+ else if (ageMs <= 14 * 86_400_000) { result.age8To14Days += 1; result.sources.age8To14Days.push(slug); }
164
+ else { result.olderThan14Days += 1; result.sources.olderThan14Days.push(slug); }
165
+ }
166
+ if (valid.length) {
167
+ result.oldestFetchedAt = new Date(Math.min(...valid)).toISOString();
168
+ result.newestFetchedAt = new Date(Math.max(...valid)).toISOString();
169
+ }
170
+ return result;
171
+ }
172
+
173
+ function confidenceCounts(jobs: JobSnapshot["partitions"][string]["jobs"]): Record<EligibilityConfidence, number> {
174
+ const counts = { explicit: 0, inferred: 0, unknown: 0 } satisfies Record<EligibilityConfidence, number>;
175
+ for (const job of jobs) counts[job.eligibilityConfidence] += 1;
176
+ return counts;
177
+ }
178
+
179
+ function validPartition(value: unknown): boolean { return isRecord(value) && validDate(value.fetchedAt) && Array.isArray(value.jobs) && value.jobs.every(validJob); }
180
+ function validJob(value: unknown): boolean {
181
+ return isRecord(value) && typeof value.id === "string" && typeof value.company === "string"
182
+ && (typeof value.title === "string" || value.title === undefined)
183
+ && typeof value.location === "string" && typeof value.remote === "boolean" && ["remote", "hybrid", "onsite", "unknown"].includes(String(value.workMode))
184
+ && arrayOfStrings(value.eligibleCountries) && arrayOfStrings(value.excludedCountries) && arrayOfStrings(value.eligibleRegions)
185
+ && ["explicit", "inferred", "unknown"].includes(String(value.eligibilityConfidence)) && typeof value.url === "string" && typeof value.description === "string";
186
+ }
187
+ function validLastCrawl(value: Record<string, unknown>): boolean {
188
+ if (!validDate(value.startedAt) || !validDate(value.finishedAt) || !nonNegativeInteger(value.selected) || !nonNegativeInteger(value.succeeded)
189
+ || !Array.isArray(value.failed) || !value.failed.every((failure) => isRecord(failure) && typeof failure.source === "string" && typeof failure.error === "string")) return false;
190
+ return value.succeeded + value.failed.length <= value.selected;
191
+ }
192
+ function validVerification(value: unknown): boolean {
193
+ return isRecord(value) && validDate(value.checkedAt) && typeof value.canonicalSourceUrl === "string" && typeof value.observedCompanyName === "string"
194
+ && ["provider_company_name", "provider_tenant", "structured_domain_link", "company_redirect"].includes(String(value.identityEvidence))
195
+ && typeof value.contentType === "string" && typeof value.payloadVersion === "string" && nonNegativeInteger(value.jobCount);
196
+ }
197
+ function validDate(value: unknown): value is string { return typeof value === "string" && Number.isFinite(Date.parse(value)); }
198
+ function validCountryCode(value: unknown): value is string { return typeof value === "string" && /^[A-Z]{2}$/u.test(value); }
199
+ function arrayOfStrings(value: unknown): value is string[] { return Array.isArray(value) && value.every((item) => typeof item === "string"); }
200
+ function nonNegativeInteger(value: unknown): value is number { return Number.isInteger(value) && (value as number) >= 0; }
201
+
202
+ function percent(numerator: number, denominator: number): number { return denominator > 0 ? Math.round(numerator / denominator * 10_000) / 100 : 0; }
203
+ function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
@@ -0,0 +1,56 @@
1
+ import type { Company, Job, JobPartition, JobSnapshot } from "./types.ts";
2
+
3
+ type Fetch = typeof globalThis.fetch;
4
+
5
+ /** Wire format for one crawled source partition posted to the aggregator. */
6
+ export interface CrawlReportPayload {
7
+ version: 1;
8
+ source: { slug: string; ats: string; token: string };
9
+ fetchedAt: string;
10
+ jobs: Job[];
11
+ }
12
+
13
+ function endpoint(base: string, path: string): string {
14
+ return new URL(path, base.endsWith("/") ? base : `${base}/`).toString();
15
+ }
16
+
17
+ /** Returns a usable aggregator base URL or undefined; anything malformed is ignored so a bad env var can never break startup. */
18
+ export function resolveAggregatorUrl(value: string | undefined): string | undefined {
19
+ const trimmed = value?.trim();
20
+ if (!trimmed) return undefined;
21
+ try {
22
+ const url = new URL(trimmed);
23
+ return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : undefined;
24
+ } catch {
25
+ return undefined;
26
+ }
27
+ }
28
+
29
+ /** Posts each successfully crawled partition to the aggregator. Only job data is sent, never resume content. */
30
+ export function createCrawlReporter(options: { url: string; fetcher?: Fetch; timeoutMs?: number }) {
31
+ const fetcher = options.fetcher ?? globalThis.fetch;
32
+ const target = endpoint(options.url, "v1/crawls");
33
+ return async (source: Company, partition: JobPartition): Promise<void> => {
34
+ const payload: CrawlReportPayload = { version: 1, source: { slug: source.slug, ats: source.ats, token: source.token }, fetchedAt: partition.fetchedAt, jobs: partition.jobs };
35
+ const response = await fetcher(target, {
36
+ method: "POST",
37
+ headers: { "content-type": "application/json", "content-encoding": "gzip" },
38
+ body: Bun.gzipSync(JSON.stringify(payload)),
39
+ signal: AbortSignal.timeout(options.timeoutMs ?? 10_000),
40
+ });
41
+ if (!response.ok) throw new Error(`Aggregator rejected crawl report: HTTP ${response.status}`);
42
+ };
43
+ }
44
+
45
+ /** Downloads the aggregator's published snapshot; returns null on any failure so setup falls back to crawling. */
46
+ export async function fetchSeedSnapshot(url: string, fetcher: Fetch = globalThis.fetch, timeoutMs = 20_000): Promise<JobSnapshot | null> {
47
+ try {
48
+ const response = await fetcher(endpoint(url, "v1/snapshot"), { signal: AbortSignal.timeout(timeoutMs) });
49
+ if (!response.ok) return null;
50
+ const snapshot = await response.json() as JobSnapshot;
51
+ if (snapshot?.version !== 1 || !snapshot.partitions || typeof snapshot.partitions !== "object" || !snapshot.lastCrawl) return null;
52
+ return snapshot;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
package/src/crawler.ts ADDED
@@ -0,0 +1,146 @@
1
+ import type { FetchJobsObserver } from "./catalog.ts";
2
+ import type { Company, CrawlFailure, CrawlReport, CrawlSourceResult, Job, JobPartition, JobSnapshot } from "./types.ts";
3
+
4
+ export interface SnapshotStore {
5
+ read(): Promise<JobSnapshot | null>;
6
+ write(snapshot: JobSnapshot): Promise<void>;
7
+ }
8
+
9
+ interface CrawlerOptions {
10
+ store: SnapshotStore;
11
+ fetchJobs(source: Company, signal?: AbortSignal, observer?: FetchJobsObserver): Promise<Job[]>;
12
+ concurrency?: number;
13
+ timeoutMs?: number;
14
+ maxAttempts?: number;
15
+ sourceStartDelayMs?: number;
16
+ sourceFreshnessMs?: number;
17
+ sourceLimit?: number;
18
+ workdayPageDelayMs?: number;
19
+ pacingNow?: () => number;
20
+ pacingSleep?: (delayMs: number) => Promise<void>;
21
+ now?: () => Date;
22
+ /** Called once per succeeded source after the snapshot is written; failures are swallowed so reporting never blocks a crawl. */
23
+ onCrawled?(source: Company, partition: JobPartition): void | Promise<void>;
24
+ }
25
+
26
+ export interface Crawler {
27
+ crawl(sources: Company[], settings?: { prune?: boolean }): Promise<CrawlReport>;
28
+ }
29
+
30
+ export function createCrawler(options: CrawlerOptions): Crawler {
31
+ const concurrency = Math.max(1, Math.trunc(options.concurrency ?? 10));
32
+ const timeoutMs = Math.max(1, Math.trunc(options.timeoutMs ?? 120_000));
33
+ const maxAttempts = Math.max(1, Math.trunc(options.maxAttempts ?? 2));
34
+ const sourceStartDelayMs = Math.max(0, Math.trunc(options.sourceStartDelayMs ?? 0));
35
+ const sourceFreshnessMs = Math.max(0, Math.trunc(options.sourceFreshnessMs ?? 0));
36
+ const sourceLimit = Math.max(0, Math.trunc(options.sourceLimit ?? 0));
37
+ const now = options.now ?? (() => new Date());
38
+ const pacingNow = options.pacingNow ?? Date.now;
39
+ const pacingSleep = options.pacingSleep ?? ((delayMs: number) => new Promise<void>((resolve) => setTimeout(resolve, delayMs)));
40
+ let previousStart: number | undefined;
41
+ let pacingGate = Promise.resolve();
42
+
43
+ async function paceSourceStart() {
44
+ if (!sourceStartDelayMs) return;
45
+ let release!: () => void;
46
+ const previous = pacingGate;
47
+ pacingGate = new Promise<void>((resolve) => { release = resolve; });
48
+ await previous;
49
+ try {
50
+ const remaining = previousStart === undefined ? 0 : previousStart + sourceStartDelayMs - pacingNow();
51
+ if (remaining > 0) await pacingSleep(remaining);
52
+ previousStart = pacingNow();
53
+ } finally { release(); }
54
+ }
55
+
56
+ return {
57
+ async crawl(sources, settings) {
58
+ const startedAt = now().toISOString();
59
+ const previous = await options.store.read();
60
+ const partitions = { ...(previous?.partitions ?? {}) };
61
+ if (settings?.prune) {
62
+ const current = new Set(sources.map((source) => source.slug));
63
+ for (const slug of Object.keys(partitions)) if (!current.has(slug)) delete partitions[slug];
64
+ }
65
+ const considered = sources.length;
66
+ const cutoff = Date.parse(startedAt) - sourceFreshnessMs;
67
+ const eligibleSources = (sourceFreshnessMs === 0 ? sources : sources.filter((source) => {
68
+ const fetchedAt = Date.parse(partitions[source.slug]?.fetchedAt ?? "");
69
+ return !Number.isFinite(fetchedAt) || fetchedAt <= cutoff;
70
+ })).sort((left, right) => partitionTime(partitions[left.slug]?.fetchedAt) - partitionTime(partitions[right.slug]?.fetchedAt));
71
+ const selectedSources = sourceLimit > 0 ? eligibleSources.slice(0, sourceLimit) : eligibleSources;
72
+ let pending = selectedSources;
73
+ let finalFailures: CrawlFailure[] = [];
74
+ let succeeded = 0;
75
+ const metrics = new Map<string, CrawlSourceResult>();
76
+ for (const source of selectedSources) metrics.set(source.slug, { source: source.slug, status: "failed", attempts: 0, durationMs: 0, jobs: 0, countryJobs: {}, throttles: 0, backoffMs: 0 });
77
+
78
+ for (let attempt = 1; attempt <= maxAttempts && pending.length; attempt += 1) {
79
+ let cursor = 0;
80
+ const retry: Company[] = [];
81
+ const failures: CrawlFailure[] = [];
82
+
83
+ async function worker() {
84
+ while (cursor < pending.length) {
85
+ const source = pending[cursor++];
86
+ if (!source) continue;
87
+ await paceSourceStart();
88
+ const metric = metrics.get(source.slug)!;
89
+ metric.attempts += 1;
90
+ const attemptStartedAt = Date.now();
91
+ const controller = new AbortController();
92
+ const timer = setTimeout(() => controller.abort(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
93
+ try {
94
+ const jobs = await options.fetchJobs(source, controller.signal, {
95
+ onBackoff: ({ status, delayMs }) => { metric.backoffMs += delayMs; if (status === 429) metric.throttles += 1; },
96
+ workdayPageDelayMs: options.workdayPageDelayMs,
97
+ });
98
+ partitions[source.slug] = { fetchedAt: now().toISOString(), jobs };
99
+ succeeded += 1;
100
+ metric.status = "succeeded";
101
+ metric.jobs = jobs.length;
102
+ metric.countryJobs = countCountries(jobs);
103
+ delete metric.error;
104
+ } catch (error) {
105
+ retry.push(source);
106
+ const message = error instanceof Error ? error.message : String(error);
107
+ failures.push({ source: source.slug, error: message });
108
+ metric.error = message;
109
+ } finally {
110
+ metric.durationMs += Date.now() - attemptStartedAt;
111
+ clearTimeout(timer);
112
+ }
113
+ }
114
+ }
115
+
116
+ await Promise.all(Array.from({ length: Math.min(concurrency, pending.length) }, worker));
117
+ pending = retry;
118
+ finalFailures = failures;
119
+ }
120
+
121
+ const finishedAt = now().toISOString();
122
+ const report: CrawlReport = {
123
+ startedAt, finishedAt, considered, selected: selectedSources.length,
124
+ cached: considered - eligibleSources.length, deferred: eligibleSources.length - selectedSources.length,
125
+ succeeded, failed: finalFailures, sources: selectedSources.map((source) => metrics.get(source.slug)!),
126
+ };
127
+ await options.store.write({ version: 1, updatedAt: finishedAt, partitions, lastCrawl: report });
128
+ if (options.onCrawled) {
129
+ const crawled = selectedSources.filter((source) => metrics.get(source.slug)!.status === "succeeded");
130
+ await Promise.all(crawled.map((source) => Promise.resolve().then(() => options.onCrawled!(source, partitions[source.slug]!)).catch(() => undefined)));
131
+ }
132
+ return report;
133
+ },
134
+ };
135
+ }
136
+
137
+ function partitionTime(value: string | undefined): number {
138
+ const timestamp = Date.parse(value ?? "");
139
+ return Number.isFinite(timestamp) ? timestamp : Number.NEGATIVE_INFINITY;
140
+ }
141
+
142
+ function countCountries(jobs: Job[]): Record<string, number> {
143
+ const counts: Record<string, number> = {};
144
+ for (const job of jobs) for (const country of job.eligibleCountries) counts[country] = (counts[country] ?? 0) + 1;
145
+ return Object.fromEntries(Object.entries(counts).sort(([left], [right]) => left.localeCompare(right)));
146
+ }