openings 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/data/companies.json +29048 -852
- package/package.json +1 -1
- package/src/board-verification.ts +180 -0
- package/src/cli.ts +29 -0
- package/src/country-coverage.ts +1 -1
- package/src/job-coverage.ts +1 -1
- package/src/mcp.ts +1 -1
- package/src/types.ts +1 -1
package/package.json
CHANGED
|
@@ -0,0 +1,180 @@
|
|
|
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 { resolveSource } from "./source-verification.ts";
|
|
5
|
+
import type { Ats, Company, SourceVerification } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
type Fetch = (input: string | URL, init?: RequestInit) => Promise<Response>;
|
|
8
|
+
|
|
9
|
+
/** Providers whose public board is accepted as identity on its own. Workday stays on the company-identity path because its crawls are expensive. */
|
|
10
|
+
export const BOARD_TIER_PROVIDERS: ReadonlySet<Ats> = new Set(["greenhouse", "lever", "ashby", "recruitee"]);
|
|
11
|
+
|
|
12
|
+
export interface BoardVerificationOptions {
|
|
13
|
+
fetch?: Fetch;
|
|
14
|
+
now?: () => Date;
|
|
15
|
+
limit?: number;
|
|
16
|
+
concurrency?: number;
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
/** Days before a board that permanently failed is probed again. */
|
|
19
|
+
cooldownDays?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface BoardVerificationReport {
|
|
23
|
+
generatedAt: string;
|
|
24
|
+
considered: number;
|
|
25
|
+
selected: number;
|
|
26
|
+
skipped: { inCatalog: number; coolingDown: number; unsupported: number; deferred: number };
|
|
27
|
+
verified: number;
|
|
28
|
+
added: string[];
|
|
29
|
+
rejected: Array<{ sourceKey: string; reason: "unreachable" | "invalid_payload" | "empty_board" | "duplicate_slug"; detail: string }>;
|
|
30
|
+
catalogSize: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type CatalogEntry = Omit<Company, "slug"> & { verification: SourceVerification };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Board-verified tier: a public ATS board is admitted on the provider's own identity (board name or tenant) without proof of
|
|
37
|
+
* the company website. Entries carry `identityEvidence: "provider_board"` and no `companyDomain`, so every consumer can tell
|
|
38
|
+
* them apart from company-verified sources.
|
|
39
|
+
*/
|
|
40
|
+
export async function verifyBoards(registryPath: string, catalogPath: string, options: BoardVerificationOptions = {}): Promise<BoardVerificationReport> {
|
|
41
|
+
const fetcher = options.fetch ?? globalThis.fetch;
|
|
42
|
+
const now = options.now ?? (() => new Date());
|
|
43
|
+
const limit = Math.max(1, Math.trunc(options.limit ?? 200));
|
|
44
|
+
const concurrency = Math.max(1, Math.trunc(options.concurrency ?? 8));
|
|
45
|
+
const timeoutMs = Math.max(1, Math.trunc(options.timeoutMs ?? 30_000));
|
|
46
|
+
const cooldownMs = Math.max(0, options.cooldownDays ?? 30) * 86_400_000;
|
|
47
|
+
|
|
48
|
+
return withFileLock(catalogPath, async () => {
|
|
49
|
+
const catalog = await readCatalog(catalogPath);
|
|
50
|
+
const registry = await readEnrichmentRegistry(registryPath);
|
|
51
|
+
const knownSources = new Set(Object.values(catalog).map((entry) => `${entry.ats}:${entry.token.toLowerCase()}`));
|
|
52
|
+
const skipped = { inCatalog: 0, coolingDown: 0, unsupported: 0, deferred: 0 };
|
|
53
|
+
const eligible: EnrichmentLead[] = [];
|
|
54
|
+
for (const lead of registry.leads) {
|
|
55
|
+
if (!BOARD_TIER_PROVIDERS.has(lead.ats) || !resolveSource(lead.sourceUrl)) { skipped.unsupported += 1; continue; }
|
|
56
|
+
if (knownSources.has(`${lead.ats}:${lead.token.toLowerCase()}`)) { skipped.inCatalog += 1; continue; }
|
|
57
|
+
if (looksLikeTestBoard(lead.token)) { skipped.unsupported += 1; continue; }
|
|
58
|
+
if (coolingDown(lead, now(), cooldownMs)) { skipped.coolingDown += 1; continue; }
|
|
59
|
+
eligible.push(lead);
|
|
60
|
+
}
|
|
61
|
+
const selected = eligible.slice(0, limit);
|
|
62
|
+
skipped.deferred = eligible.length - selected.length;
|
|
63
|
+
|
|
64
|
+
const usedSlugs = new Set(Object.keys(catalog));
|
|
65
|
+
const added: string[] = [];
|
|
66
|
+
const rejected: BoardVerificationReport["rejected"] = [];
|
|
67
|
+
const updatedLeads: EnrichmentLead[] = [];
|
|
68
|
+
let cursor = 0;
|
|
69
|
+
async function worker() {
|
|
70
|
+
while (cursor < selected.length) {
|
|
71
|
+
const lead = selected[cursor++]!;
|
|
72
|
+
const attemptedAt = now().toISOString();
|
|
73
|
+
try {
|
|
74
|
+
const probe = await probeBoard(lead, fetcher, timeoutMs);
|
|
75
|
+
const name = probe.providerName || lead.companyMatches[0]?.companyName || humanize(lead.token);
|
|
76
|
+
const source = resolveSource(lead.sourceUrl)!;
|
|
77
|
+
const slug = uniqueSlug(lead, usedSlugs);
|
|
78
|
+
if (!slug) {
|
|
79
|
+
rejected.push({ sourceKey: lead.sourceKey, reason: "duplicate_slug", detail: `Catalog slug already used for ${lead.token}` });
|
|
80
|
+
updatedLeads.push(withAttempt(lead, { attemptedAt, outcome: "permanent_failure", category: "duplicate_slug", detail: "slug collision" }));
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
usedSlugs.add(slug);
|
|
84
|
+
catalog[slug] = {
|
|
85
|
+
name, ats: lead.ats, token: source.token, sourceUrl: source.canonicalSourceUrl,
|
|
86
|
+
discoveredFrom: lead.discoveredFrom[0] ?? { channel: "dataset", reference: registryPath },
|
|
87
|
+
verification: { observedCompanyName: name, identityEvidence: "provider_board", contentType: probe.contentType, payloadVersion: probe.payloadVersion, jobCount: probe.jobCount, checkedAt: attemptedAt, canonicalSourceUrl: source.canonicalSourceUrl },
|
|
88
|
+
};
|
|
89
|
+
added.push(slug);
|
|
90
|
+
updatedLeads.push({ ...withAttempt(lead, { attemptedAt, outcome: "success", category: "provider_board", detail: `${probe.jobCount} jobs` }), promotedAt: attemptedAt });
|
|
91
|
+
} catch (error) {
|
|
92
|
+
const reason = error instanceof BoardError ? error.reason : "unreachable";
|
|
93
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
94
|
+
const outcome = error instanceof BoardError && error.reason !== "unreachable" ? "permanent_failure" : "transient_failure";
|
|
95
|
+
rejected.push({ sourceKey: lead.sourceKey, reason, detail });
|
|
96
|
+
updatedLeads.push(withAttempt(lead, { attemptedAt, outcome, category: reason, detail }));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, selected.length) }, worker));
|
|
101
|
+
|
|
102
|
+
if (added.length) await atomicJson(catalogPath, Object.fromEntries(Object.entries(catalog).sort(([a], [b]) => a.localeCompare(b))));
|
|
103
|
+
if (updatedLeads.length) await mergeEnrichmentLeads(registryPath, updatedLeads, now());
|
|
104
|
+
return {
|
|
105
|
+
generatedAt: now().toISOString(), considered: registry.leads.length, selected: selected.length, skipped,
|
|
106
|
+
verified: added.length, added: added.sort(), rejected: rejected.sort((a, b) => a.sourceKey.localeCompare(b.sourceKey)), catalogSize: Object.keys(catalog).length,
|
|
107
|
+
};
|
|
108
|
+
}, { operation: "verify boards" });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 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. */
|
|
112
|
+
export function looksLikeTestBoard(token: string): boolean {
|
|
113
|
+
const value = token.toLowerCase();
|
|
114
|
+
return /[a-z]/.test(value) && (/\d{5,}/.test(value) || (value.length >= 8 && !/[aeiouy]/.test(value)));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
class BoardError extends Error {
|
|
118
|
+
constructor(readonly reason: "unreachable" | "invalid_payload" | "empty_board", message: string) { super(message); }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function probeBoard(lead: EnrichmentLead, fetcher: Fetch, timeoutMs: number): Promise<{ providerName: string; contentType: string; payloadVersion: string; jobCount: number }> {
|
|
122
|
+
const source = resolveSource(lead.sourceUrl)!;
|
|
123
|
+
const controller = new AbortController();
|
|
124
|
+
const timer = setTimeout(() => controller.abort(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
125
|
+
try {
|
|
126
|
+
const response = await fetcher(source.structuredEndpoint, { signal: controller.signal });
|
|
127
|
+
if (!response.ok) { await response.body?.cancel().catch(() => undefined); throw new BoardError(response.status === 404 || response.status === 410 ? "invalid_payload" : "unreachable", `HTTP ${response.status}`); }
|
|
128
|
+
let body: unknown;
|
|
129
|
+
try { body = await response.json(); } catch { throw new BoardError("invalid_payload", "Endpoint did not return JSON"); }
|
|
130
|
+
const jobs = source.ats === "lever" ? asArray(body) : asArray(isRecord(body) ? body[source.ats === "recruitee" ? "offers" : "jobs"] : undefined);
|
|
131
|
+
if (!jobs) throw new BoardError("invalid_payload", "Payload does not contain the expected jobs array");
|
|
132
|
+
if (jobs.length === 0) throw new BoardError("empty_board", "Board has no jobs");
|
|
133
|
+
const providerName = source.ats === "greenhouse" ? majority(jobs.map((job) => typeof job.company_name === "string" ? job.company_name.trim() : "").filter(Boolean)) : "";
|
|
134
|
+
const payloadVersion = 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"}`;
|
|
135
|
+
return { providerName, contentType: response.headers.get("content-type") ?? "unknown", payloadVersion, jobCount: jobs.length };
|
|
136
|
+
} finally { clearTimeout(timer); }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function coolingDown(lead: EnrichmentLead, now: Date, cooldownMs: number): boolean {
|
|
140
|
+
const last = lead.attempts[lead.attempts.length - 1];
|
|
141
|
+
if (!last) return false;
|
|
142
|
+
const age = now.getTime() - Date.parse(last.attemptedAt);
|
|
143
|
+
if (last.outcome === "permanent_failure") return age < cooldownMs;
|
|
144
|
+
if (last.outcome === "transient_failure") return age < 86_400_000;
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function withAttempt(lead: EnrichmentLead, attempt: LeadAttempt): EnrichmentLead {
|
|
149
|
+
return { ...lead, attempts: [...lead.attempts, attempt] };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function uniqueSlug(lead: EnrichmentLead, used: Set<string>): string | null {
|
|
153
|
+
const base = lead.token.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || lead.ats;
|
|
154
|
+
for (const candidate of [base, `${lead.ats}-${base}`]) if (!used.has(candidate)) return candidate;
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** "acme-corp" -> "Acme Corp". Used only when the provider exposes no company name. */
|
|
159
|
+
export function humanize(token: string): string {
|
|
160
|
+
return token.split(/[-_.]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function readCatalog(path: string): Promise<Record<string, CatalogEntry>> {
|
|
164
|
+
try {
|
|
165
|
+
const parsed = JSON.parse(await Bun.file(path).text()) as unknown;
|
|
166
|
+
if (!isRecord(parsed)) throw new Error(`Catalog is not an object: ${path}`);
|
|
167
|
+
return parsed as Record<string, CatalogEntry>;
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return {};
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function majority(values: string[]): string {
|
|
175
|
+
const counts = new Map<string, number>();
|
|
176
|
+
for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1);
|
|
177
|
+
return [...counts].sort((a, b) => b[1] - a[1])[0]?.[0] ?? "";
|
|
178
|
+
}
|
|
179
|
+
function asArray(value: unknown): Record<string, unknown>[] | null { return Array.isArray(value) && value.every(isRecord) ? value : null; }
|
|
180
|
+
function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
package/src/cli.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { relative, resolve } from "node:path";
|
|
4
4
|
import { createRuntime } from "./runtime.ts";
|
|
5
|
+
import { atomicJson } from "./atomic-file.ts";
|
|
6
|
+
import { verifyBoards } from "./board-verification.ts";
|
|
5
7
|
import { runSourceVerification } from "./source-pipeline.ts";
|
|
6
8
|
import { runSourceDiscovery, runYcSourceDiscovery } from "./source-discovery.ts";
|
|
7
9
|
import { discoverAndPromote } from "./source-discovery-pipeline.ts";
|
|
@@ -21,6 +23,7 @@ Usage:
|
|
|
21
23
|
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
24
|
openings snapshot export [--input FILE] [--output-dir PATH]
|
|
23
25
|
openings coverage report --country CODE [--snapshot FILE] [--catalog FILE] [--candidates FILE] [--registry FILE] [--output FILE] [--as-of ISO]
|
|
26
|
+
openings sources verify-boards REGISTRY.json [--output FILE] [--limit N] [--concurrency N] [--report FILE]
|
|
24
27
|
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
28
|
openings sources discover FEED.json [--country CODE] [--registry FILE] [--output FILE] [--catalog FILE] [--report FILE]
|
|
26
29
|
openings sources discover-yc --country CODE [--registry FILE] [--output FILE] [--catalog FILE] [--report FILE]
|
|
@@ -156,6 +159,14 @@ export async function run(args: string[]): Promise<number> {
|
|
|
156
159
|
)), null, 2));
|
|
157
160
|
return 0;
|
|
158
161
|
}
|
|
162
|
+
if (rest[0] === "verify-boards") {
|
|
163
|
+
const parsed = parseVerifyBoards(rest.slice(1));
|
|
164
|
+
if (typeof parsed === "string") return fail(parsed);
|
|
165
|
+
const report = await verifyBoards(parsed.registry, parsed.output, { limit: parsed.limit, concurrency: parsed.concurrency });
|
|
166
|
+
if (parsed.report) await atomicJson(parsed.report, report);
|
|
167
|
+
console.log(JSON.stringify(report, null, 2));
|
|
168
|
+
return 0;
|
|
169
|
+
}
|
|
159
170
|
if (rest[0] !== "verify") return fail("sources requires a discovery, tracing, or `verify` subcommand");
|
|
160
171
|
const parsed = parseSourceVerification(rest.slice(1));
|
|
161
172
|
if (typeof parsed === "string") return fail(parsed);
|
|
@@ -533,6 +544,24 @@ async function readCompanyFile(path: string): Promise<string[]> {
|
|
|
533
544
|
return (await readFile(path, "utf8")).split(/\r?\n/).map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
|
|
534
545
|
}
|
|
535
546
|
|
|
547
|
+
function parseVerifyBoards(args: string[]): { registry: string; output: string; limit?: number; concurrency?: number; report?: string } | string {
|
|
548
|
+
const registry = args[0];
|
|
549
|
+
if (!registry || registry.startsWith("--")) return "sources verify-boards requires a registry JSON file";
|
|
550
|
+
let output = "data/companies.json";
|
|
551
|
+
let limit: number | undefined;
|
|
552
|
+
let concurrency: number | undefined;
|
|
553
|
+
let report: string | undefined;
|
|
554
|
+
for (let index = 1; index < args.length; index += 1) {
|
|
555
|
+
const arg = args[index];
|
|
556
|
+
if (arg === "--output") output = args[++index] ?? output;
|
|
557
|
+
else if (arg === "--report") report = args[++index];
|
|
558
|
+
else if (arg === "--limit") { limit = Number(args[++index]); if (!Number.isInteger(limit) || limit < 1) return "--limit must be a positive integer"; }
|
|
559
|
+
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"; }
|
|
560
|
+
else return `Unknown option: ${arg}`;
|
|
561
|
+
}
|
|
562
|
+
return { registry, output, limit, concurrency, report };
|
|
563
|
+
}
|
|
564
|
+
|
|
536
565
|
function parseSourceVerification(args: string[]) {
|
|
537
566
|
const candidatesPath = args[0];
|
|
538
567
|
if (!candidatesPath || candidatesPath.startsWith("--")) return "sources verify requires a candidate JSON file";
|
package/src/country-coverage.ts
CHANGED
|
@@ -111,7 +111,7 @@ async function readCatalog(path: string): Promise<Record<string, Omit<VerifiedCo
|
|
|
111
111
|
if (!isRecord(value)) throw new Error(`Invalid verified catalog: ${path}`);
|
|
112
112
|
for (const [slug, company] of Object.entries(value)) {
|
|
113
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
|
+
|| (typeof company.companyDomain !== "string" && !(isRecord(company.verification) && company.verification.identityEvidence === "provider_board")) || typeof company.sourceUrl !== "string"
|
|
115
115
|
|| company.cohorts !== undefined && (!Array.isArray(company.cohorts) || !company.cohorts.every(validCountryCode))
|
|
116
116
|
|| !validVerification(company.verification)) throw new Error(`Invalid verified catalog source: ${slug}`);
|
|
117
117
|
}
|
package/src/job-coverage.ts
CHANGED
|
@@ -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.
|
|
25
|
+
return { ...base, result: { protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "openings", version: "0.1.3" } } };
|
|
26
26
|
}
|
|
27
27
|
if (request.method === "ping") return { ...base, result: {} };
|
|
28
28
|
if (request.method === "tools/list") return { ...base, result: { tools: tools.list() } };
|
package/src/types.ts
CHANGED
|
@@ -39,7 +39,7 @@ export interface SourceVerification {
|
|
|
39
39
|
checkedAt: string;
|
|
40
40
|
canonicalSourceUrl: string;
|
|
41
41
|
observedCompanyName: string;
|
|
42
|
-
identityEvidence: "provider_company_name" | "provider_tenant" | "structured_domain_link" | "company_redirect";
|
|
42
|
+
identityEvidence: "provider_company_name" | "provider_tenant" | "structured_domain_link" | "company_redirect" | "provider_board";
|
|
43
43
|
contentType: string;
|
|
44
44
|
payloadVersion: string;
|
|
45
45
|
jobCount: number;
|