openings 0.1.3 → 0.1.5
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 +1 -1
- package/data/companies.json +33544 -978
- package/package.json +2 -2
- package/src/board-verification.ts +6 -4
- package/src/catalog.ts +19 -0
- package/src/cli.ts +32 -3
- package/src/common-crawl-discovery.ts +5 -3
- package/src/country-coverage.ts +2 -1
- package/src/crawl-reporting.ts +4 -2
- package/src/enrichment-registry.ts +3 -2
- package/src/job-search-preparation.ts +2 -2
- package/src/mcp.ts +1 -1
- package/src/providers.ts +183 -0
- package/src/runtime.ts +1 -1
- package/src/source-verification.ts +13 -7
- package/src/subdomain-discovery.ts +85 -0
- package/src/types.ts +2 -1
|
@@ -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";
|