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/.codex-plugin/plugin.json +2 -2
- package/README.md +2 -2
- package/data/companies.json +62577 -1815
- package/package.json +2 -2
- package/src/board-verification.ts +182 -0
- package/src/catalog.ts +19 -0
- package/src/cli.ts +61 -3
- package/src/common-crawl-discovery.ts +5 -3
- package/src/country-coverage.ts +3 -2
- package/src/enrichment-registry.ts +3 -2
- package/src/job-coverage.ts +1 -1
- package/src/mcp.ts +1 -1
- package/src/providers.ts +183 -0
- package/src/source-verification.ts +13 -7
- package/src/subdomain-discovery.ts +85 -0
- package/src/types.ts +3 -2
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openings",
|
|
3
|
-
"version": "0.1.
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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);
|
package/src/country-coverage.ts
CHANGED
|
@@ -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) || !
|
|
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" &&
|
|
114
|
+
&& typeof lead.sourceKey === "string" && new RegExp(`^(${ALL_PROVIDERS.join("|")}):.+`).test(lead.sourceKey)
|
|
114
115
|
&& typeof lead.sourceUrl === "string" && typeof lead.token === "string"
|
|
115
|
-
&&
|
|
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)
|
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.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() } };
|
package/src/providers.ts
ADDED
|
@@ -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(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/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); }
|