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.
@@ -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 ["greenhouse", "lever", "ashby", "workday", "recruitee"] as const) {
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 jobs = 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");
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 hasDomainLink = !["greenhouse", "workday"].includes(source.ats) && structuredIdentityLinksDomain(jobs, candidate.companyDomain);
170
- const hasRedirectEvidence = !["greenhouse", "workday"].includes(source.ats) && await verifiedCompanyRedirect(candidate, source, resolveHost, headTransport, timeoutMs);
171
- if (!["greenhouse", "workday"].includes(source.ats) && !hasDomainLink && !hasRedirectEvidence) throw new VerificationError("identity_mismatch", `Neither structured identity fields nor a verified company redirect link to ${candidate.companyDomain}`);
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 type Ats = "greenhouse" | "lever" | "ashby" | "workday" | "recruitee";
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;