openings 0.1.2 → 0.1.4

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/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "openings",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "A free, candidate-safe job-search substrate for AI agents",
5
5
  "license": "MIT",
6
6
  "repository": { "type": "git", "url": "git+https://github.com/abhay-avagama/hiring-agent.git" },
7
7
  "homepage": "https://github.com/abhay-avagama/hiring-agent#readme",
8
8
  "bugs": { "url": "https://github.com/abhay-avagama/hiring-agent/issues" },
9
- "keywords": ["jobs", "resume", "matching", "mcp", "greenhouse", "lever", "ashby", "workday", "recruitee"],
9
+ "keywords": ["jobs", "resume", "matching", "mcp", "greenhouse", "lever", "ashby", "workday", "recruitee", "smartrecruiters", "workable", "breezy"],
10
10
  "type": "module",
11
11
  "bin": { "openings-mcp": "./src/package-mcp.ts" },
12
12
  "exports": "./src/index.ts",
@@ -0,0 +1,182 @@
1
+ import { atomicJson } from "./atomic-file.ts";
2
+ import { readEnrichmentRegistry, mergeEnrichmentLeads, type EnrichmentLead, type LeadAttempt } from "./enrichment-registry.ts";
3
+ import { withFileLock } from "./file-lock.ts";
4
+ import { providerSpec } from "./providers.ts";
5
+ import { resolveSource } from "./source-verification.ts";
6
+ import type { Ats, Company, SourceVerification } from "./types.ts";
7
+
8
+ type Fetch = (input: string | URL, init?: RequestInit) => Promise<Response>;
9
+
10
+ /** Providers whose public board is accepted as identity on its own. Workday stays on the company-identity path because its crawls are expensive. */
11
+ export const BOARD_TIER_PROVIDERS: ReadonlySet<Ats> = new Set(["greenhouse", "lever", "ashby", "recruitee", "smartrecruiters", "workable", "breezy"]);
12
+
13
+ export interface BoardVerificationOptions {
14
+ fetch?: Fetch;
15
+ now?: () => Date;
16
+ limit?: number;
17
+ concurrency?: number;
18
+ timeoutMs?: number;
19
+ /** Days before a board that permanently failed is probed again. */
20
+ cooldownDays?: number;
21
+ }
22
+
23
+ export interface BoardVerificationReport {
24
+ generatedAt: string;
25
+ considered: number;
26
+ selected: number;
27
+ skipped: { inCatalog: number; coolingDown: number; unsupported: number; deferred: number };
28
+ verified: number;
29
+ added: string[];
30
+ rejected: Array<{ sourceKey: string; reason: "unreachable" | "invalid_payload" | "empty_board" | "duplicate_slug"; detail: string }>;
31
+ catalogSize: number;
32
+ }
33
+
34
+ type CatalogEntry = Omit<Company, "slug"> & { verification: SourceVerification };
35
+
36
+ /**
37
+ * Board-verified tier: a public ATS board is admitted on the provider's own identity (board name or tenant) without proof of
38
+ * the company website. Entries carry `identityEvidence: "provider_board"` and no `companyDomain`, so every consumer can tell
39
+ * them apart from company-verified sources.
40
+ */
41
+ export async function verifyBoards(registryPath: string, catalogPath: string, options: BoardVerificationOptions = {}): Promise<BoardVerificationReport> {
42
+ const fetcher = options.fetch ?? globalThis.fetch;
43
+ const now = options.now ?? (() => new Date());
44
+ const limit = Math.max(1, Math.trunc(options.limit ?? 200));
45
+ const concurrency = Math.max(1, Math.trunc(options.concurrency ?? 8));
46
+ const timeoutMs = Math.max(1, Math.trunc(options.timeoutMs ?? 30_000));
47
+ const cooldownMs = Math.max(0, options.cooldownDays ?? 30) * 86_400_000;
48
+
49
+ return withFileLock(catalogPath, async () => {
50
+ const catalog = await readCatalog(catalogPath);
51
+ const registry = await readEnrichmentRegistry(registryPath);
52
+ const knownSources = new Set(Object.values(catalog).map((entry) => `${entry.ats}:${entry.token.toLowerCase()}`));
53
+ const skipped = { inCatalog: 0, coolingDown: 0, unsupported: 0, deferred: 0 };
54
+ const eligible: EnrichmentLead[] = [];
55
+ for (const lead of registry.leads) {
56
+ if (!BOARD_TIER_PROVIDERS.has(lead.ats) || !resolveSource(lead.sourceUrl)) { skipped.unsupported += 1; continue; }
57
+ if (knownSources.has(`${lead.ats}:${lead.token.toLowerCase()}`)) { skipped.inCatalog += 1; continue; }
58
+ if (looksLikeTestBoard(lead.token)) { skipped.unsupported += 1; continue; }
59
+ if (coolingDown(lead, now(), cooldownMs)) { skipped.coolingDown += 1; continue; }
60
+ eligible.push(lead);
61
+ }
62
+ const selected = eligible.slice(0, limit);
63
+ skipped.deferred = eligible.length - selected.length;
64
+
65
+ const usedSlugs = new Set(Object.keys(catalog));
66
+ const added: string[] = [];
67
+ const rejected: BoardVerificationReport["rejected"] = [];
68
+ const updatedLeads: EnrichmentLead[] = [];
69
+ let cursor = 0;
70
+ async function worker() {
71
+ while (cursor < selected.length) {
72
+ const lead = selected[cursor++]!;
73
+ const attemptedAt = now().toISOString();
74
+ try {
75
+ const probe = await probeBoard(lead, fetcher, timeoutMs);
76
+ const name = probe.providerName || lead.companyMatches[0]?.companyName || humanize(lead.token);
77
+ const source = resolveSource(lead.sourceUrl)!;
78
+ const slug = uniqueSlug(lead, usedSlugs);
79
+ if (!slug) {
80
+ rejected.push({ sourceKey: lead.sourceKey, reason: "duplicate_slug", detail: `Catalog slug already used for ${lead.token}` });
81
+ updatedLeads.push(withAttempt(lead, { attemptedAt, outcome: "permanent_failure", category: "duplicate_slug", detail: "slug collision" }));
82
+ continue;
83
+ }
84
+ usedSlugs.add(slug);
85
+ catalog[slug] = {
86
+ name, ats: lead.ats, token: source.token, sourceUrl: source.canonicalSourceUrl,
87
+ discoveredFrom: lead.discoveredFrom[0] ?? { channel: "dataset", reference: registryPath },
88
+ verification: { observedCompanyName: name, identityEvidence: "provider_board", contentType: probe.contentType, payloadVersion: probe.payloadVersion, jobCount: probe.jobCount, checkedAt: attemptedAt, canonicalSourceUrl: source.canonicalSourceUrl },
89
+ };
90
+ added.push(slug);
91
+ updatedLeads.push({ ...withAttempt(lead, { attemptedAt, outcome: "success", category: "provider_board", detail: `${probe.jobCount} jobs` }), promotedAt: attemptedAt });
92
+ } catch (error) {
93
+ const reason = error instanceof BoardError ? error.reason : "unreachable";
94
+ const detail = error instanceof Error ? error.message : String(error);
95
+ const outcome = error instanceof BoardError && error.reason !== "unreachable" ? "permanent_failure" : "transient_failure";
96
+ rejected.push({ sourceKey: lead.sourceKey, reason, detail });
97
+ updatedLeads.push(withAttempt(lead, { attemptedAt, outcome, category: reason, detail }));
98
+ }
99
+ }
100
+ }
101
+ await Promise.all(Array.from({ length: Math.min(concurrency, selected.length) }, worker));
102
+
103
+ if (added.length) await atomicJson(catalogPath, Object.fromEntries(Object.entries(catalog).sort(([a], [b]) => a.localeCompare(b))));
104
+ if (updatedLeads.length) await mergeEnrichmentLeads(registryPath, updatedLeads, now());
105
+ return {
106
+ generatedAt: now().toISOString(), considered: registry.leads.length, selected: selected.length, skipped,
107
+ verified: added.length, added: added.sort(), rejected: rejected.sort((a, b) => a.sourceKey.localeCompare(b.sourceKey)), catalogSize: Object.keys(catalog).length,
108
+ };
109
+ }, { operation: "verify boards" });
110
+ }
111
+
112
+ /** Random-looking tokens are almost always someone's test board: a long digit run mixed with letters, or a long consonant string. Short brand tokens like 2k or bvnk pass. */
113
+ export function looksLikeTestBoard(token: string): boolean {
114
+ const value = token.toLowerCase();
115
+ return /[a-z]/.test(value) && (/\d{5,}/.test(value) || (value.length >= 8 && !/[aeiouy]/.test(value)));
116
+ }
117
+
118
+ class BoardError extends Error {
119
+ constructor(readonly reason: "unreachable" | "invalid_payload" | "empty_board", message: string) { super(message); }
120
+ }
121
+
122
+ async function probeBoard(lead: EnrichmentLead, fetcher: Fetch, timeoutMs: number): Promise<{ providerName: string; contentType: string; payloadVersion: string; jobCount: number }> {
123
+ const source = resolveSource(lead.sourceUrl)!;
124
+ const controller = new AbortController();
125
+ const timer = setTimeout(() => controller.abort(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
126
+ try {
127
+ const response = await fetcher(source.structuredEndpoint, { signal: controller.signal });
128
+ if (!response.ok) { await response.body?.cancel().catch(() => undefined); throw new BoardError(response.status === 404 || response.status === 410 ? "invalid_payload" : "unreachable", `HTTP ${response.status}`); }
129
+ let body: unknown;
130
+ try { body = await response.json(); } catch { throw new BoardError("invalid_payload", "Endpoint did not return JSON"); }
131
+ const spec = providerSpec(source.ats);
132
+ const jobs = spec ? spec.jobsFromBody(body) : source.ats === "lever" ? asArray(body) : asArray(isRecord(body) ? body[source.ats === "recruitee" ? "offers" : "jobs"] : undefined);
133
+ if (!jobs) throw new BoardError("invalid_payload", "Payload does not contain the expected jobs array");
134
+ if (jobs.length === 0) throw new BoardError("empty_board", "Board has no jobs");
135
+ const providerName = spec ? spec.providerName(jobs, body) : source.ats === "greenhouse" ? majority(jobs.map((job) => typeof job.company_name === "string" ? job.company_name.trim() : "").filter(Boolean)) : "";
136
+ const payloadVersion = spec ? spec.payloadVersion(body) : source.ats === "greenhouse" ? "greenhouse-job-board:v1" : source.ats === "lever" ? "lever-postings:v0" : source.ats === "recruitee" ? "recruitee-careers:v1" : `ashby-job-board:${isRecord(body) && typeof body.apiVersion === "string" ? body.apiVersion : "unknown"}`;
137
+ return { providerName, contentType: response.headers.get("content-type") ?? "unknown", payloadVersion, jobCount: jobs.length };
138
+ } finally { clearTimeout(timer); }
139
+ }
140
+
141
+ function coolingDown(lead: EnrichmentLead, now: Date, cooldownMs: number): boolean {
142
+ const last = lead.attempts[lead.attempts.length - 1];
143
+ if (!last) return false;
144
+ const age = now.getTime() - Date.parse(last.attemptedAt);
145
+ if (last.outcome === "permanent_failure") return age < cooldownMs;
146
+ if (last.outcome === "transient_failure") return age < 86_400_000;
147
+ return false;
148
+ }
149
+
150
+ function withAttempt(lead: EnrichmentLead, attempt: LeadAttempt): EnrichmentLead {
151
+ return { ...lead, attempts: [...lead.attempts, attempt] };
152
+ }
153
+
154
+ function uniqueSlug(lead: EnrichmentLead, used: Set<string>): string | null {
155
+ const base = lead.token.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || lead.ats;
156
+ for (const candidate of [base, `${lead.ats}-${base}`]) if (!used.has(candidate)) return candidate;
157
+ return null;
158
+ }
159
+
160
+ /** "acme-corp" -> "Acme Corp". Used only when the provider exposes no company name. */
161
+ export function humanize(token: string): string {
162
+ return token.split(/[-_.]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
163
+ }
164
+
165
+ async function readCatalog(path: string): Promise<Record<string, CatalogEntry>> {
166
+ try {
167
+ const parsed = JSON.parse(await Bun.file(path).text()) as unknown;
168
+ if (!isRecord(parsed)) throw new Error(`Catalog is not an object: ${path}`);
169
+ return parsed as Record<string, CatalogEntry>;
170
+ } catch (error) {
171
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return {};
172
+ throw error;
173
+ }
174
+ }
175
+
176
+ function majority(values: string[]): string {
177
+ const counts = new Map<string, number>();
178
+ for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1);
179
+ return [...counts].sort((a, b) => b[1] - a[1])[0]?.[0] ?? "";
180
+ }
181
+ function asArray(value: unknown): Record<string, unknown>[] | null { return Array.isArray(value) && value.every(isRecord) ? value : null; }
182
+ function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
package/src/catalog.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Company, Job, JobSummary, SearchQuery } from "./types.ts";
2
+ import { providerSpec, type JsonGet } from "./providers.ts";
2
3
  import { classifyJob, isEligibleForCountry, normalizeLocation } from "./locations.ts";
3
4
 
4
5
  type Fetch = (input: string | URL, init?: RequestInit) => Promise<Response>;
@@ -100,6 +101,8 @@ export function createCatalog(options: CatalogOptions): Catalog {
100
101
  const description = await fetchWorkdayDescription(company, job.url, fetcher);
101
102
  job = { ...job, description };
102
103
  }
104
+ const spec = job && !job.description.trim() ? providerSpec(company.ats) : undefined;
105
+ if (job && spec?.detail) job = { ...job, description: await spec.detail(company, job, jsonGetter(fetcher, company.name)) };
103
106
  if (job && !job.description.trim()) throw new Error(`Full description unavailable for job: ${id}`);
104
107
  return job;
105
108
  },
@@ -118,6 +121,13 @@ export interface FetchJobsObserver {
118
121
  }
119
122
  export async function fetchSourceJobs(company: Company, fetcher: Fetch = globalThis.fetch, signal?: AbortSignal, observer?: FetchJobsObserver): Promise<Job[]> {
120
123
  if (company.ats === "workday") return fetchWorkdayJobs(company, fetcher, signal, observer);
124
+ const spec = providerSpec(company.ats);
125
+ if (spec) {
126
+ const get = jsonGetter(fetcher, company.name, signal, observer);
127
+ const records = spec.fetchAll ? await spec.fetchAll(company.token, get) : spec.jobsFromBody(await get(spec.endpoint(company.token)));
128
+ if (!records) throw new Error(`${company.name} job board returned an invalid payload`);
129
+ return records.map((record) => spec.normalize(company, record));
130
+ }
121
131
  const url = company.ats === "greenhouse"
122
132
  ? `https://boards-api.greenhouse.io/v1/boards/${encodeURIComponent(company.token)}/jobs?content=true`
123
133
  : company.ats === "lever"
@@ -196,6 +206,15 @@ async function fetchWorkdayJobs(company: Company, fetcher: Fetch, signal?: Abort
196
206
  return jobs.map((job) => normalizeWorkday(company, source, job));
197
207
  }
198
208
 
209
+ /** JSON fetch with the catalog's retry and backoff, shaped for the table-driven providers. */
210
+ function jsonGetter(fetcher: Fetch, companyName: string, signal?: AbortSignal, observer?: FetchJobsObserver): JsonGet {
211
+ return async (url) => {
212
+ const response = await fetchWithRetry(fetcher, url, signal ? { signal } : undefined, companyName, observer);
213
+ if (!response.ok) { await response.body?.cancel().catch(() => undefined); throw new Error(`${companyName} job board returned HTTP ${response.status}`); }
214
+ return response.json();
215
+ };
216
+ }
217
+
199
218
  async function fetchWithRetry(fetcher: Fetch, input: string | URL, init: RequestInit | undefined, companyName: string, observer?: FetchJobsObserver): Promise<Response> {
200
219
  for (let attempt = 0; attempt < 3; attempt += 1) {
201
220
  const response = await fetcher(input, init);
package/src/cli.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  #!/usr/bin/env bun
2
+ import { ALL_PROVIDERS, type Ats } from "./types.ts";
2
3
  import { readFile } from "node:fs/promises";
3
4
  import { relative, resolve } from "node:path";
4
5
  import { createRuntime } from "./runtime.ts";
6
+ import { atomicJson } from "./atomic-file.ts";
7
+ import { verifyBoards } from "./board-verification.ts";
8
+ import { discoverCareerSubdomains } from "./subdomain-discovery.ts";
5
9
  import { runSourceVerification } from "./source-pipeline.ts";
6
10
  import { runSourceDiscovery, runYcSourceDiscovery } from "./source-discovery.ts";
7
11
  import { discoverAndPromote } from "./source-discovery-pipeline.ts";
@@ -21,11 +25,13 @@ Usage:
21
25
  openings crawl [--country CODE | --companies FILE] [--concurrency N] [--source-cache-hours N] [--source-limit N] [--delay-ms N] [--workday-page-delay-ms N] [--data-dir PATH]
22
26
  openings snapshot export [--input FILE] [--output-dir PATH]
23
27
  openings coverage report --country CODE [--snapshot FILE] [--catalog FILE] [--candidates FILE] [--registry FILE] [--output FILE] [--as-of ISO]
28
+ openings sources discover-subdomains SEEDS.json [--output FILE] [--limit N] [--delay-ms N] [--report FILE]
29
+ openings sources verify-boards REGISTRY.json [--output FILE] [--limit N] [--concurrency N] [--report FILE]
24
30
  openings sources verify CANDIDATES.json [--output FILE] [--state-file FILE] [--concurrency N] [--workday-concurrency N] [--limit N] [--require-country CODE] [--registry FILE] [--retry-deferred]
25
31
  openings sources discover FEED.json [--country CODE] [--registry FILE] [--output FILE] [--catalog FILE] [--report FILE]
26
32
  openings sources discover-yc --country CODE [--registry FILE] [--output FILE] [--catalog FILE] [--report FILE]
27
33
  openings sources seed-companies-yc --country CODE [--output FILE]
28
- openings sources discover-common-crawl [--country CODE] [--provider recruitee] [--index-record-limit N] [--sample-token-limit N] [--exclude-token TOKEN] [--report-only] [--registry FILE] [--output FILE] [--report FILE] [--index-url URL]
34
+ openings sources discover-common-crawl [--country CODE] [--provider NAME] [--index-record-limit N] [--sample-token-limit N] [--exclude-token TOKEN] [--report-only] [--registry FILE] [--output FILE] [--report FILE] [--index-url URL]
29
35
  openings sources enrich COMPANIES.json [--companies FILE]... [--evidence-kind authoritative_dataset|company_registry] [--registry FILE] [--output FILE] [--report FILE]
30
36
  openings sources trace-careers COMPANIES.json [--country CODE] [--registry FILE] [--common-crawl-report FILE] [--search-key-env NAME] [--output FILE] [--catalog FILE] [--report FILE]
31
37
  openings sources probe-jobposting COMPANIES.md [--catalog FILE] [--report FILE] [--company-limit 10|20]
@@ -156,6 +162,22 @@ export async function run(args: string[]): Promise<number> {
156
162
  )), null, 2));
157
163
  return 0;
158
164
  }
165
+ if (rest[0] === "discover-subdomains") {
166
+ const parsed = parseDiscoverSubdomains(rest.slice(1));
167
+ if (typeof parsed === "string") return fail(parsed);
168
+ const report = await discoverCareerSubdomains(parsed.seeds, parsed.output, { limit: parsed.limit, delayMs: parsed.delayMs });
169
+ if (parsed.report) await atomicJson(parsed.report, report);
170
+ console.log(JSON.stringify({ ...report, seeds: report.seeds.length }, null, 2));
171
+ return 0;
172
+ }
173
+ if (rest[0] === "verify-boards") {
174
+ const parsed = parseVerifyBoards(rest.slice(1));
175
+ if (typeof parsed === "string") return fail(parsed);
176
+ const report = await verifyBoards(parsed.registry, parsed.output, { limit: parsed.limit, concurrency: parsed.concurrency });
177
+ if (parsed.report) await atomicJson(parsed.report, report);
178
+ console.log(JSON.stringify(report, null, 2));
179
+ return 0;
180
+ }
159
181
  if (rest[0] !== "verify") return fail("sources requires a discovery, tracing, or `verify` subcommand");
160
182
  const parsed = parseSourceVerification(rest.slice(1));
161
183
  if (typeof parsed === "string") return fail(parsed);
@@ -307,7 +329,7 @@ function parseCommonCrawlDiscovery(args: string[]) {
307
329
  let country: string | undefined;
308
330
  let indexUrl: string | undefined;
309
331
  let registryPath: string | undefined = "data/enrichment-leads.json";
310
- let provider: "recruitee" | undefined;
332
+ let provider: Ats | undefined;
311
333
  let indexRecordLimit: number | undefined;
312
334
  let sampleTokenLimit: number | undefined;
313
335
  const excludeTokens: string[] = [];
@@ -320,7 +342,7 @@ function parseCommonCrawlDiscovery(args: string[]) {
320
342
  else if (arg === "--index-url") { indexUrl = args[++index]; if (!indexUrl) return "--index-url requires a URL"; try { new URL(indexUrl); } catch { return "--index-url requires a valid URL"; } }
321
343
  else if (arg === "--registry") { if (reportOnly) return "Use either --registry or --report-only, not both"; registryPath = args[++index] ?? ""; if (!registryPath) return "--registry requires a file"; }
322
344
  else if (arg === "--report-only") { if (registryPath !== "data/enrichment-leads.json") return "Use either --registry or --report-only, not both"; reportOnly = true; registryPath = undefined; }
323
- else if (arg === "--provider") { const value = args[++index]; if (value !== "recruitee") return "--provider currently supports only recruitee"; provider = value; }
345
+ else if (arg === "--provider") { const value = args[++index]; if (!value || !(ALL_PROVIDERS as readonly string[]).includes(value)) return `--provider must be one of ${ALL_PROVIDERS.join(", ")}`; provider = value as Ats; }
324
346
  else if (arg === "--index-record-limit") { indexRecordLimit = Number(args[++index]); if (!Number.isInteger(indexRecordLimit) || indexRecordLimit < 1 || indexRecordLimit > 2_000) return "--index-record-limit must be an integer from 1 to 2000"; }
325
347
  else if (arg === "--sample-token-limit") { sampleTokenLimit = Number(args[++index]); if (!Number.isInteger(sampleTokenLimit) || sampleTokenLimit < 1 || sampleTokenLimit > 60) return "--sample-token-limit must be an integer from 1 to 60"; }
326
348
  else if (arg === "--exclude-token") { const value = args[++index]?.trim(); if (!value || !/^[a-z0-9-]+$/i.test(value)) return "--exclude-token requires an ATS token"; excludeTokens.push(value.toLowerCase()); }
@@ -533,6 +555,42 @@ async function readCompanyFile(path: string): Promise<string[]> {
533
555
  return (await readFile(path, "utf8")).split(/\r?\n/).map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
534
556
  }
535
557
 
558
+ function parseDiscoverSubdomains(args: string[]): { seeds: string; output: string; limit?: number; delayMs?: number; report?: string } | string {
559
+ const seeds = args[0];
560
+ if (!seeds || seeds.startsWith("--")) return "sources discover-subdomains requires a seed JSON file of { companyName, companyDomain }";
561
+ let output = ".openings/career-seeds-subdomains.json";
562
+ let limit: number | undefined;
563
+ let delayMs: number | undefined;
564
+ let report: string | undefined;
565
+ for (let index = 1; index < args.length; index += 1) {
566
+ const arg = args[index];
567
+ if (arg === "--output") output = args[++index] ?? output;
568
+ else if (arg === "--report") report = args[++index];
569
+ else if (arg === "--limit") { limit = Number(args[++index]); if (!Number.isInteger(limit) || limit < 1) return "--limit must be a positive integer"; }
570
+ else if (arg === "--delay-ms") { delayMs = Number(args[++index]); if (!Number.isInteger(delayMs) || delayMs < 0) return "--delay-ms must be a non-negative integer"; }
571
+ else return `Unknown option: ${arg}`;
572
+ }
573
+ return { seeds, output, limit, delayMs, report };
574
+ }
575
+
576
+ function parseVerifyBoards(args: string[]): { registry: string; output: string; limit?: number; concurrency?: number; report?: string } | string {
577
+ const registry = args[0];
578
+ if (!registry || registry.startsWith("--")) return "sources verify-boards requires a registry JSON file";
579
+ let output = "data/companies.json";
580
+ let limit: number | undefined;
581
+ let concurrency: number | undefined;
582
+ let report: string | undefined;
583
+ for (let index = 1; index < args.length; index += 1) {
584
+ const arg = args[index];
585
+ if (arg === "--output") output = args[++index] ?? output;
586
+ else if (arg === "--report") report = args[++index];
587
+ else if (arg === "--limit") { limit = Number(args[++index]); if (!Number.isInteger(limit) || limit < 1) return "--limit must be a positive integer"; }
588
+ else if (arg === "--concurrency") { concurrency = Number(args[++index]); if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 50) return "--concurrency must be an integer from 1 to 50"; }
589
+ else return `Unknown option: ${arg}`;
590
+ }
591
+ return { registry, output, limit, concurrency, report };
592
+ }
593
+
536
594
  function parseSourceVerification(args: string[]) {
537
595
  const candidatesPath = args[0];
538
596
  if (!candidatesPath || candidatesPath.startsWith("--")) return "sources verify requires a candidate JSON file";
@@ -2,6 +2,7 @@ import { readFile } from "node:fs/promises";
2
2
  import { atomicJson } from "./atomic-file.ts";
3
3
  import { stampReport, type ReportMeta } from "./report-meta.ts";
4
4
  import { mergeEnrichmentLeads, type EnrichmentLead } from "./enrichment-registry.ts";
5
+ import { PROVIDERS } from "./providers.ts";
5
6
  import { resolveSource } from "./source-verification.ts";
6
7
  import type { Ats } from "./types.ts";
7
8
  import { assertArtifactFile } from "./artifact-path.ts";
@@ -36,11 +37,12 @@ export interface CommonCrawlDiscoveryReport extends ReportMeta {
36
37
  rejections: Array<{ value: string; reason: string }>;
37
38
  }
38
39
 
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
40
  const providerPatterns: Record<Ats, string[]> = {
42
- greenhouse: patterns.slice(0, 2), lever: [patterns[2]!], ashby: [patterns[3]!], workday: [patterns[4]!], recruitee: [patterns[5]!],
41
+ greenhouse: ["job-boards.greenhouse.io/*", "boards.greenhouse.io/*"], lever: ["jobs.lever.co/*"], ashby: ["jobs.ashbyhq.com/*"], workday: ["*.myworkdayjobs.com/*"], recruitee: ["*.recruitee.com/*"],
42
+ ...Object.fromEntries(PROVIDERS.map((spec) => [spec.ats, spec.crawlPatterns])) as Record<"smartrecruiters" | "workable" | "breezy", string[]>,
43
43
  };
44
+ const patterns = Object.values(providerPatterns).flat();
45
+ const recordsPerPattern = 10_000;
44
46
 
45
47
  export async function discoverCommonCrawlSources(candidatesPath: string, reportPath: string, options: Options = {}): Promise<CommonCrawlDiscoveryReport> {
46
48
  if (options.provider === "recruitee") await assertArtifactFile(reportPath);
@@ -4,6 +4,7 @@ import { deriveLeadState, readEnrichmentRegistry, type EnrichmentState } from ".
4
4
  import { isEligibleForCountry } from "./locations.ts";
5
5
  import { stampReport, type ReportMeta } from "./report-meta.ts";
6
6
  import { resolveSource } from "./source-verification.ts";
7
+ import { ALL_PROVIDERS } from "./types.ts";
7
8
  import type { Ats, EligibilityConfidence, JobSnapshot, SourceCandidate, VerifiedCompany } from "./types.ts";
8
9
  import { projectJobCoverage } from "./job-coverage.ts";
9
10
 
@@ -110,8 +111,8 @@ async function readCatalog(path: string): Promise<Record<string, Omit<VerifiedCo
110
111
  const value: unknown = JSON.parse(await readFile(path, "utf8"));
111
112
  if (!isRecord(value)) throw new Error(`Invalid verified catalog: ${path}`);
112
113
  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"
114
+ if (!isRecord(company) || !(ALL_PROVIDERS as readonly string[]).includes(String(company.ats)) || typeof company.token !== "string"
115
+ || (typeof company.companyDomain !== "string" && !(isRecord(company.verification) && company.verification.identityEvidence === "provider_board")) || typeof company.sourceUrl !== "string"
115
116
  || company.cohorts !== undefined && (!Array.isArray(company.cohorts) || !company.cohorts.every(validCountryCode))
116
117
  || !validVerification(company.verification)) throw new Error(`Invalid verified catalog source: ${slug}`);
117
118
  }
@@ -1,6 +1,7 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { atomicJson } from "./atomic-file.ts";
3
3
  import { withFileLock } from "./file-lock.ts";
4
+ import { ALL_PROVIDERS } from "./types.ts";
4
5
  import type { Ats, DiscoveryProvenance, DomainEvidence } from "./types.ts";
5
6
  import { resolveSource } from "./source-verification.ts";
6
7
 
@@ -110,9 +111,9 @@ function consecutiveTransientFailures(attempts: LeadAttempt[]): number { let cou
110
111
  function isRegistry(value: unknown): value is EnrichmentRegistry {
111
112
  if (!isRecord(value) || value.version !== 1 || typeof value.updatedAt !== "string" || !Array.isArray(value.leads)) return false;
112
113
  return value.leads.every((lead) => isRecord(lead)
113
- && typeof lead.sourceKey === "string" && /^(greenhouse|lever|ashby|workday|recruitee):.+/.test(lead.sourceKey)
114
+ && typeof lead.sourceKey === "string" && new RegExp(`^(${ALL_PROVIDERS.join("|")}):.+`).test(lead.sourceKey)
114
115
  && typeof lead.sourceUrl === "string" && typeof lead.token === "string"
115
- && ["greenhouse", "lever", "ashby", "workday", "recruitee"].includes(String(lead.ats))
116
+ && (ALL_PROVIDERS as readonly string[]).includes(String(lead.ats))
116
117
  && Array.isArray(lead.discoveredFrom) && lead.discoveredFrom.every(validProvenance)
117
118
  && Array.isArray(lead.companyMatches) && lead.companyMatches.every(validMatch)
118
119
  && Array.isArray(lead.identityEvidence) && lead.identityEvidence.every(validEvidence)
@@ -40,7 +40,7 @@ export function projectJobCoverage(sources: Company[], snapshot: JobSnapshot, co
40
40
  country,
41
41
  indexedSourcesWithEligibleJobs: eligibleSources.length,
42
42
  eligibleJobs: eligibleSources.reduce((total, { jobs }) => total + jobs.filter((job) => isEligibleForCountry(job, country)).length, 0),
43
- distinctEligibleEmployers: new Set(eligibleSources.flatMap(({ source }) => source.companyDomain ? [normalizeDomain(source.companyDomain)] : [])).size,
43
+ distinctEligibleEmployers: new Set(eligibleSources.flatMap(({ source }) => source.companyDomain ? [normalizeDomain(source.companyDomain)] : source.verification?.identityEvidence === "provider_board" ? [`board:${source.ats}:${source.token.toLowerCase()}`] : [])).size,
44
44
  };
45
45
  }),
46
46
  };
package/src/mcp.ts CHANGED
@@ -22,7 +22,7 @@ export function createMcpHandler(tools: ToolHandler) {
22
22
  const base = { jsonrpc: "2.0" as const, id: request.id ?? null };
23
23
  try {
24
24
  if (request.method === "initialize") {
25
- return { ...base, result: { protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "openings", version: "0.1.2" } } };
25
+ return { ...base, result: { protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "openings", version: "0.1.4" } } };
26
26
  }
27
27
  if (request.method === "ping") return { ...base, result: {} };
28
28
  if (request.method === "tools/list") return { ...base, result: { tools: tools.list() } };
@@ -0,0 +1,183 @@
1
+ import { classifyJob } from "./locations.ts";
2
+ import type { Ats, Company, Job } from "./types.ts";
3
+
4
+ /**
5
+ * Table-driven adapters for providers added after the original five. Each entry knows how to recognise a board URL,
6
+ * where its public structured endpoint lives, how to read the payload, and how to turn one record into a Job.
7
+ * The original providers keep their hand-written paths; new ones only need a row here.
8
+ */
9
+
10
+ type Rec = Record<string, unknown>;
11
+ /** Fetches a URL and returns its parsed JSON body; the caller supplies retry and pacing. */
12
+ export type JsonGet = (url: string) => Promise<unknown>;
13
+
14
+ export interface ProviderSpec {
15
+ ats: Ats;
16
+ label: string;
17
+ /** Registrable host suffixes on which this provider serves boards and job pages. */
18
+ hosts: string[];
19
+ /** Common Crawl URL patterns that surface this provider's boards. */
20
+ crawlPatterns: string[];
21
+ resolve(url: URL): string | null;
22
+ canonicalUrl(token: string): string;
23
+ endpoint(token: string): string;
24
+ jobsFromBody(body: unknown): Rec[] | null;
25
+ providerName(jobs: Rec[], body: unknown): string;
26
+ payloadVersion(body: unknown): string;
27
+ normalize(company: Company, job: Rec): Job;
28
+ /** Fetches every record when the endpoint paginates. Defaults to one request to `endpoint`. */
29
+ fetchAll?(token: string, get: JsonGet): Promise<Rec[]>;
30
+ /** Fetches the full description for one job when the listing omits it. */
31
+ detail?(company: Company, job: Job, get: JsonGet): Promise<string>;
32
+ }
33
+
34
+ const PAGE = 100;
35
+ const MAX_RECORDS = 2000;
36
+
37
+ const smartrecruiters: ProviderSpec = {
38
+ ats: "smartrecruiters",
39
+ label: "SmartRecruiters",
40
+ hosts: ["smartrecruiters.com"],
41
+ crawlPatterns: ["jobs.smartrecruiters.com/*", "careers.smartrecruiters.com/*"],
42
+ resolve(url) {
43
+ const parts = url.pathname.split("/").filter(Boolean);
44
+ if (url.hostname === "jobs.smartrecruiters.com" || url.hostname === "careers.smartrecruiters.com") return validToken(parts[0]);
45
+ if (url.hostname === "api.smartrecruiters.com" && parts[0] === "v1" && parts[1] === "companies") return validToken(parts[2]);
46
+ return null;
47
+ },
48
+ canonicalUrl: (token) => `https://jobs.smartrecruiters.com/${token}`,
49
+ endpoint: (token) => `https://api.smartrecruiters.com/v1/companies/${encodeURIComponent(token)}/postings?limit=${PAGE}&offset=0`,
50
+ jobsFromBody: (body) => (isRecord(body) ? asRecords(body.content) : null),
51
+ providerName: (jobs) => majority(jobs.map((job) => (isRecord(job.company) ? str(job.company.name) : ""))),
52
+ payloadVersion: () => "smartrecruiters-postings:v1",
53
+ async fetchAll(token, get) {
54
+ const records: Rec[] = [];
55
+ for (let offset = 0; offset < MAX_RECORDS; offset += PAGE) {
56
+ const body = await get(`https://api.smartrecruiters.com/v1/companies/${encodeURIComponent(token)}/postings?limit=${PAGE}&offset=${offset}`);
57
+ const page = smartrecruiters.jobsFromBody(body);
58
+ if (!page) throw new Error("SmartRecruiters returned an invalid postings payload");
59
+ records.push(...page);
60
+ const total = isRecord(body) && typeof body.totalFound === "number" ? body.totalFound : records.length;
61
+ if (page.length < PAGE || records.length >= total) break;
62
+ }
63
+ return records;
64
+ },
65
+ normalize(company, job) {
66
+ const loc = isRecord(job.location) ? job.location : {};
67
+ const country = str(loc.country);
68
+ const location = str(loc.fullLocation) || [str(loc.city), str(loc.region), country.length === 2 ? country.toUpperCase() : country].filter(Boolean).join(", ") || "Unspecified";
69
+ const remote = loc.remote === true;
70
+ return classifyJob({
71
+ id: `smartrecruiters:${company.slug}:${str(job.id)}`, company: company.name, title: str(job.name), location,
72
+ remote, workMode: remote ? "remote" : loc.hybrid === true ? "hybrid" : "unknown",
73
+ eligibleCountries: [], excludedCountries: [], eligibleRegions: [], eligibilityConfidence: "unknown",
74
+ url: `https://jobs.smartrecruiters.com/${company.token}/${encodeURIComponent(str(job.id))}`, updatedAt: str(job.releasedDate) || undefined, description: "",
75
+ });
76
+ },
77
+ async detail(company, job, get) {
78
+ const id = job.id.split(":")[2] ?? "";
79
+ const body = await get(`https://api.smartrecruiters.com/v1/companies/${encodeURIComponent(company.token)}/postings/${encodeURIComponent(id)}`);
80
+ const sections = isRecord(body) && isRecord(body.jobAd) && isRecord(body.jobAd.sections) ? body.jobAd.sections : {};
81
+ return ["jobDescription", "qualifications", "additionalInformation"].map((key) => { const section = sections[key]; return isRecord(section) ? plainText(str(section.text)) : ""; }).filter(Boolean).join("\n\n");
82
+ },
83
+ };
84
+
85
+ const workable: ProviderSpec = {
86
+ ats: "workable",
87
+ label: "Workable",
88
+ hosts: ["workable.com"],
89
+ crawlPatterns: ["apply.workable.com/*"],
90
+ resolve(url) {
91
+ const parts = url.pathname.split("/").filter(Boolean);
92
+ if (url.hostname === "apply.workable.com") return parts[0] && !["j", "api", "jobs", "embed", "assets"].includes(parts[0]) ? validToken(parts[0]) : null;
93
+ const match = /^([a-z0-9-]+)\.workable\.com$/i.exec(url.hostname);
94
+ if (match && !["www", "apply", "help", "resources", "jobs", "careers", "api", "app", "assets", "cdn", "status", "developers", "blog", "partners"].includes(match[1]!.toLowerCase())) return match[1]!.toLowerCase();
95
+ return null;
96
+ },
97
+ canonicalUrl: (token) => `https://apply.workable.com/${token}/`,
98
+ endpoint: (token) => `https://apply.workable.com/api/v1/widget/accounts/${encodeURIComponent(token)}`,
99
+ jobsFromBody: (body) => (isRecord(body) ? asRecords(body.jobs) : null),
100
+ providerName: (_jobs, body) => (isRecord(body) ? str(body.name) : ""),
101
+ payloadVersion: () => "workable-widget:v1",
102
+ normalize(company, job) {
103
+ const first = asRecords(job.locations)?.[0];
104
+ const location = [str(job.city), str(job.state), str(job.country) || (first ? str(first.country) : "")].filter(Boolean).join(", ") || "Unspecified";
105
+ const remote = job.telecommuting === true;
106
+ return classifyJob({
107
+ id: `workable:${company.slug}:${str(job.shortcode)}`, company: company.name, title: str(job.title), location,
108
+ remote, workMode: remote ? "remote" : "unknown",
109
+ eligibleCountries: [], excludedCountries: [], eligibleRegions: [], eligibilityConfidence: "unknown",
110
+ url: str(job.url) || `https://apply.workable.com/j/${encodeURIComponent(str(job.shortcode))}`, updatedAt: str(job.published_on) || undefined, description: "",
111
+ });
112
+ },
113
+ async detail(company, job, get) {
114
+ const shortcode = job.id.split(":")[2] ?? "";
115
+ const body = await get(`https://apply.workable.com/api/v2/accounts/${encodeURIComponent(company.token)}/jobs/${encodeURIComponent(shortcode)}`);
116
+ if (!isRecord(body)) return "";
117
+ return ["description", "requirements", "benefits"].map((key) => plainText(str(body[key]))).filter(Boolean).join("\n\n");
118
+ },
119
+ };
120
+
121
+ const breezy: ProviderSpec = {
122
+ ats: "breezy",
123
+ label: "Breezy",
124
+ hosts: ["breezy.hr"],
125
+ crawlPatterns: ["*.breezy.hr/*"],
126
+ resolve(url) {
127
+ const match = /^([a-z0-9-]+)\.breezy\.hr$/i.exec(url.hostname);
128
+ return match && !["www", "app", "api", "help", "blog"].includes(match[1]!.toLowerCase()) ? match[1]!.toLowerCase() : null;
129
+ },
130
+ canonicalUrl: (token) => `https://${token}.breezy.hr`,
131
+ endpoint: (token) => `https://${token}.breezy.hr/json?verbose=true`,
132
+ jobsFromBody: (body) => asRecords(body),
133
+ providerName: (jobs) => majority(jobs.map((job) => (isRecord(job.company) ? str(job.company.name) : ""))),
134
+ payloadVersion: () => "breezy-positions:v1",
135
+ normalize(company, job) {
136
+ const loc = isRecord(job.location) ? job.location : {};
137
+ const location = str(loc.name) || [str(loc.city), isRecord(loc.state) ? str(loc.state.name) : "", isRecord(loc.country) ? str(loc.country.name) : ""].filter(Boolean).join(", ") || "Unspecified";
138
+ const remote = loc.is_remote === true;
139
+ return classifyJob({
140
+ id: `breezy:${company.slug}:${str(job.id)}`, company: company.name, title: str(job.name), location,
141
+ remote, workMode: remote ? "remote" : "unknown",
142
+ eligibleCountries: [], excludedCountries: [], eligibleRegions: [], eligibilityConfidence: "unknown",
143
+ url: str(job.url) || `https://${company.token}.breezy.hr/p/${encodeURIComponent(str(job.friendly_id) || str(job.id))}`, updatedAt: str(job.published_date) || undefined, description: plainText(str(job.description)),
144
+ });
145
+ },
146
+ };
147
+
148
+ export const PROVIDERS: ReadonlyArray<ProviderSpec> = [smartrecruiters, workable, breezy];
149
+
150
+ export function providerSpec(ats: string): ProviderSpec | undefined {
151
+ return PROVIDERS.find((spec) => spec.ats === ats);
152
+ }
153
+
154
+ /** Resolves a URL against the table-driven providers; returns null when none claims it. */
155
+ export function resolveProviderSource(value: string): { ats: Ats; token: string; canonicalSourceUrl: string; structuredEndpoint: string } | null {
156
+ let url: URL;
157
+ try { url = new URL(value); } catch { return null; }
158
+ for (const spec of PROVIDERS) {
159
+ const token = spec.resolve(url);
160
+ if (token) return { ats: spec.ats, token, canonicalSourceUrl: spec.canonicalUrl(token), structuredEndpoint: spec.endpoint(token) };
161
+ }
162
+ return null;
163
+ }
164
+
165
+ function validToken(value: string | undefined): string | null {
166
+ return value && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value) ? value : null;
167
+ }
168
+
169
+ export function plainText(value: string): string {
170
+ return value
171
+ .replace(/<br\s*\/?\s*>/gi, "\n").replace(/<\/(p|li|div|h[1-6])>/gi, "\n").replace(/<[^>]+>/g, "")
172
+ .replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'")
173
+ .replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
174
+ }
175
+
176
+ function majority(values: string[]): string {
177
+ const counts = new Map<string, number>();
178
+ for (const value of values) if (value) counts.set(value, (counts.get(value) ?? 0) + 1);
179
+ return [...counts].sort((a, b) => b[1] - a[1])[0]?.[0] ?? "";
180
+ }
181
+ function str(value: unknown): string { return typeof value === "string" ? value.trim() : typeof value === "number" ? String(value) : ""; }
182
+ function asRecords(value: unknown): Rec[] | null { return Array.isArray(value) && value.every(isRecord) ? value : null; }
183
+ function isRecord(value: unknown): value is Rec { return typeof value === "object" && value !== null && !Array.isArray(value); }