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
|
@@ -2,6 +2,8 @@ import type { Ats, RejectedSource, SourceCandidate, SourceRejectionReason, Sourc
|
|
|
2
2
|
import { fetchSafeHead, type HeadTransport, type ResolveHost } from "./safe-head.ts";
|
|
3
3
|
import { abortableDelay, fetchSourceJobs, isTransientStatus, retryDelayMs } from "./catalog.ts";
|
|
4
4
|
import { isEligibleForCountry } from "./locations.ts";
|
|
5
|
+
import { providerSpec, resolveProviderSource } from "./providers.ts";
|
|
6
|
+
import { ALL_PROVIDERS } from "./types.ts";
|
|
5
7
|
|
|
6
8
|
type Fetch = (input: string | URL, init?: RequestInit) => Promise<Response>;
|
|
7
9
|
|
|
@@ -29,7 +31,7 @@ export async function verifyCandidates(candidates: SourceCandidate[], options: V
|
|
|
29
31
|
const rejected: Array<{ index: number; value: RejectedSource }> = [];
|
|
30
32
|
const providerLimits = new Map<Ats, Semaphore>();
|
|
31
33
|
const providerCooldowns = new Map<Ats, ProviderCooldown>();
|
|
32
|
-
for (const ats of
|
|
34
|
+
for (const ats of ALL_PROVIDERS) {
|
|
33
35
|
const fallback = ats === "workday" ? 2 : concurrency;
|
|
34
36
|
providerLimits.set(ats, new Semaphore(Math.max(1, Math.trunc(options.providerConcurrency?.[ats] ?? fallback))));
|
|
35
37
|
providerCooldowns.set(ats, new ProviderCooldown());
|
|
@@ -162,19 +164,21 @@ async function probe(candidate: SourceCandidate, source: ResolvedSource, fetcher
|
|
|
162
164
|
const contentType = response.headers.get("content-type") ?? "unknown";
|
|
163
165
|
let body: unknown;
|
|
164
166
|
try { body = await response.json(); } catch { throw new VerificationError("invalid_payload", "Endpoint did not return JSON"); }
|
|
165
|
-
const
|
|
167
|
+
const spec = providerSpec(source.ats);
|
|
168
|
+
const jobs = spec ? spec.jobsFromBody(body) : source.ats === "greenhouse" ? recordArray(body, "jobs") : source.ats === "lever" ? array(body) : source.ats === "workday" ? recordArray(body, "jobPostings") : source.ats === "recruitee" ? recordArray(body, "offers") : recordArray(body, "jobs");
|
|
166
169
|
if (!jobs) throw new VerificationError("invalid_payload", "Payload does not contain the expected jobs array");
|
|
167
170
|
if (jobs.length === 0) throw new VerificationError("empty_board", "Source has no jobs, so identity cannot be verified");
|
|
168
|
-
const providerName = source.ats === "greenhouse" ? majority(jobs.map((job) => stringField(job, "company_name")).filter(Boolean)) : source.ats === "workday" ? workday!.tenant : "";
|
|
169
|
-
const
|
|
170
|
-
const
|
|
171
|
-
|
|
171
|
+
const providerName = spec ? spec.providerName(jobs, body) : source.ats === "greenhouse" ? majority(jobs.map((job) => stringField(job, "company_name")).filter(Boolean)) : source.ats === "workday" ? workday!.tenant : "";
|
|
172
|
+
const namedProvider = source.ats === "greenhouse" || source.ats === "workday" || Boolean(spec && providerName);
|
|
173
|
+
const hasDomainLink = !namedProvider && structuredIdentityLinksDomain(jobs, candidate.companyDomain);
|
|
174
|
+
const hasRedirectEvidence = !namedProvider && await verifiedCompanyRedirect(candidate, source, resolveHost, headTransport, timeoutMs);
|
|
175
|
+
if (!namedProvider && !hasDomainLink && !hasRedirectEvidence) throw new VerificationError("identity_mismatch", `Neither structured identity fields nor a verified company redirect link to ${candidate.companyDomain}`);
|
|
172
176
|
const observedCompanyName = providerName || candidate.companyName;
|
|
173
177
|
return {
|
|
174
178
|
observedCompanyName,
|
|
175
179
|
identityEvidence: source.ats === "workday" ? "provider_tenant" as const : (providerName ? "provider_company_name" as const : hasDomainLink ? "structured_domain_link" as const : "company_redirect" as const),
|
|
176
180
|
contentType,
|
|
177
|
-
payloadVersion: source.ats === "greenhouse" ? "greenhouse-job-board:v1" : source.ats === "lever" ? "lever-postings:v0" : source.ats === "workday" ? "workday-cxs:v1" : source.ats === "recruitee" ? "recruitee-careers:v1" : `ashby-job-board:${isRecord(body) && typeof body.apiVersion === "string" ? body.apiVersion : "unknown"}`,
|
|
181
|
+
payloadVersion: spec ? spec.payloadVersion(body) : source.ats === "greenhouse" ? "greenhouse-job-board:v1" : source.ats === "lever" ? "lever-postings:v0" : source.ats === "workday" ? "workday-cxs:v1" : source.ats === "recruitee" ? "recruitee-careers:v1" : `ashby-job-board:${isRecord(body) && typeof body.apiVersion === "string" ? body.apiVersion : "unknown"}`,
|
|
178
182
|
jobCount: source.ats === "workday" && isRecord(body) && typeof body.total === "number" ? body.total : jobs.length,
|
|
179
183
|
};
|
|
180
184
|
} finally { clearTimeout(timer); }
|
|
@@ -192,6 +196,8 @@ async function fetchProbeWithRetry(fetcher: Fetch, endpoint: string, init: Reque
|
|
|
192
196
|
}
|
|
193
197
|
|
|
194
198
|
export function resolveSource(value: string): ResolvedSource | null {
|
|
199
|
+
const tableDriven = resolveProviderSource(value);
|
|
200
|
+
if (tableDriven) return tableDriven;
|
|
195
201
|
let url: URL;
|
|
196
202
|
try { url = new URL(value); } catch { return null; }
|
|
197
203
|
const parts = url.pathname.split("/").filter(Boolean);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { promises as dns } from "node:dns";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { atomicJson } from "./atomic-file.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Passive careers-subdomain discovery. For each company domain it reads certificate transparency logs (no requests to the
|
|
7
|
+
* company) plus a short conventional list, keeps names that look like a careers host, confirms each resolves in DNS, and
|
|
8
|
+
* emits tracer seeds. The career tracer then makes the single safe HEAD request that turns a subdomain into a board.
|
|
9
|
+
* Never brute-forces names and never fetches a page.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
type Fetch = (input: string | URL, init?: RequestInit) => Promise<Response>;
|
|
13
|
+
type Resolve = (hostname: string) => Promise<string[]>;
|
|
14
|
+
|
|
15
|
+
export interface SubdomainSeed { companyName: string; companyDomain: string }
|
|
16
|
+
export interface SubdomainDiscoveryReport {
|
|
17
|
+
generatedAt: string;
|
|
18
|
+
companies: number;
|
|
19
|
+
certificateNames: number;
|
|
20
|
+
candidates: number;
|
|
21
|
+
resolved: number;
|
|
22
|
+
seeds: Array<SubdomainSeed & { careerUrl: string; via: "certificate_transparency" | "conventional_name" }>;
|
|
23
|
+
failures: Array<{ companyDomain: string; reason: "ct_request_failed" | "invalid_domain"; detail: string }>;
|
|
24
|
+
outputPath: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const CAREER_WORDS = new Set(["careers", "career", "jobs", "job", "apply", "hiring", "talent", "talents", "recruit", "recruiting", "recruitment", "join", "joinus", "workwithus", "opportunities", "vacancies", "openings"]);
|
|
28
|
+
const CONVENTIONAL = ["careers", "jobs", "apply", "hiring", "talent", "recruit", "join"];
|
|
29
|
+
|
|
30
|
+
/** True when the subdomain part (everything before the company domain) reads like a careers host. */
|
|
31
|
+
export function looksLikeCareersHost(subdomain: string): boolean {
|
|
32
|
+
const words = subdomain.toLowerCase().split(/[.-]+/).filter(Boolean);
|
|
33
|
+
return words.some((word) => CAREER_WORDS.has(word)) || (words.includes("work") && words.includes("us"));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function discoverCareerSubdomains(seedsPath: string, outputPath: string, options: { fetch?: Fetch; resolve?: Resolve; now?: () => Date; limit?: number; delayMs?: number } = {}): Promise<SubdomainDiscoveryReport> {
|
|
37
|
+
const fetcher = options.fetch ?? globalThis.fetch;
|
|
38
|
+
const resolve = options.resolve ?? (async (hostname: string) => { try { return await dns.resolve(hostname, "A"); } catch { try { return await dns.resolve(hostname, "CNAME"); } catch { return []; } } });
|
|
39
|
+
const now = options.now ?? (() => new Date());
|
|
40
|
+
const delayMs = Math.max(0, options.delayMs ?? 1_000);
|
|
41
|
+
const seeds = (await readSeeds(seedsPath)).slice(0, options.limit ?? Number.POSITIVE_INFINITY);
|
|
42
|
+
const report: SubdomainDiscoveryReport = { generatedAt: now().toISOString(), companies: seeds.length, certificateNames: 0, candidates: 0, resolved: 0, seeds: [], failures: [], outputPath };
|
|
43
|
+
|
|
44
|
+
for (const [index, seed] of seeds.entries()) {
|
|
45
|
+
const domain = seed.companyDomain.toLowerCase().replace(/^www\./, "");
|
|
46
|
+
if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) { report.failures.push({ companyDomain: seed.companyDomain, reason: "invalid_domain", detail: "companyDomain must be a hostname" }); continue; }
|
|
47
|
+
const candidates = new Map<string, "certificate_transparency" | "conventional_name">();
|
|
48
|
+
for (const name of CONVENTIONAL) candidates.set(`${name}.${domain}`, "conventional_name");
|
|
49
|
+
try {
|
|
50
|
+
if (index > 0 && delayMs) await new Promise((done) => setTimeout(done, delayMs));
|
|
51
|
+
const names = await certificateNames(domain, fetcher);
|
|
52
|
+
report.certificateNames += names.length;
|
|
53
|
+
for (const name of names) if (looksLikeCareersHost(name.slice(0, -domain.length - 1))) candidates.set(name, candidates.get(name) ?? "certificate_transparency");
|
|
54
|
+
} catch (error) {
|
|
55
|
+
report.failures.push({ companyDomain: domain, reason: "ct_request_failed", detail: error instanceof Error ? error.message : String(error) });
|
|
56
|
+
}
|
|
57
|
+
report.candidates += candidates.size;
|
|
58
|
+
for (const [host, via] of candidates) {
|
|
59
|
+
if ((await resolve(host)).length === 0) continue;
|
|
60
|
+
report.resolved += 1;
|
|
61
|
+
report.seeds.push({ companyName: seed.companyName, companyDomain: domain, careerUrl: `https://${host}/`, via });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
await atomicJson(outputPath, report.seeds.map(({ companyName, companyDomain, careerUrl }) => ({ companyName, companyDomain, careerUrl })));
|
|
65
|
+
return report;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Distinct hostnames under `domain` that appear in certificate transparency logs. One request per company. */
|
|
69
|
+
export async function certificateNames(domain: string, fetcher: Fetch): Promise<string[]> {
|
|
70
|
+
const response = await fetcher(`https://crt.sh/?q=${encodeURIComponent(`%.${domain}`)}&output=json`, { headers: { "user-agent": "openings-discovery/0.1 (+https://avagama.co/openings/)" }, signal: AbortSignal.timeout(60_000) });
|
|
71
|
+
if (!response.ok) throw new Error(`crt.sh returned HTTP ${response.status}`);
|
|
72
|
+
const rows = await response.json() as Array<{ name_value?: string }>;
|
|
73
|
+
const names = new Set<string>();
|
|
74
|
+
for (const row of rows) for (const raw of String(row.name_value ?? "").split("\n")) {
|
|
75
|
+
const name = raw.trim().toLowerCase().replace(/^\*\./, "");
|
|
76
|
+
if (name.endsWith(`.${domain}`) && /^[a-z0-9.-]+$/.test(name)) names.add(name);
|
|
77
|
+
}
|
|
78
|
+
return [...names].sort();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function readSeeds(path: string): Promise<SubdomainSeed[]> {
|
|
82
|
+
const value = JSON.parse(await readFile(path, "utf8")) as unknown;
|
|
83
|
+
if (!Array.isArray(value) || !value.every((row) => typeof row === "object" && row !== null && typeof (row as SubdomainSeed).companyName === "string" && typeof (row as SubdomainSeed).companyDomain === "string")) throw new Error("Seed file must be a JSON array of { companyName, companyDomain }");
|
|
84
|
+
return value as SubdomainSeed[];
|
|
85
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export
|
|
1
|
+
export const ALL_PROVIDERS = ["greenhouse", "lever", "ashby", "workday", "recruitee", "smartrecruiters", "workable", "breezy"] as const;
|
|
2
|
+
export type Ats = (typeof ALL_PROVIDERS)[number];
|
|
2
3
|
|
|
3
4
|
export interface DomainEvidence {
|
|
4
5
|
kind: "authoritative_dataset" | "company_registry" | "company_redirect";
|
|
@@ -39,7 +40,7 @@ export interface SourceVerification {
|
|
|
39
40
|
checkedAt: string;
|
|
40
41
|
canonicalSourceUrl: string;
|
|
41
42
|
observedCompanyName: string;
|
|
42
|
-
identityEvidence: "provider_company_name" | "provider_tenant" | "structured_domain_link" | "company_redirect";
|
|
43
|
+
identityEvidence: "provider_company_name" | "provider_tenant" | "structured_domain_link" | "company_redirect" | "provider_board";
|
|
43
44
|
contentType: string;
|
|
44
45
|
payloadVersion: string;
|
|
45
46
|
jobCount: number;
|