openings 0.1.17 → 0.1.19
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 +2 -0
- package/data/companies.json +3246 -0
- package/package.json +1 -1
- package/src/board-verification.ts +5 -4
- package/src/catalog.ts +9 -3
- package/src/cli.ts +36 -0
- package/src/common-crawl-discovery.ts +8 -2
- package/src/country-coverage.ts +1 -1
- package/src/jobposting-site.ts +162 -0
- package/src/keka-tenants.ts +46 -0
- package/src/locations.ts +4 -0
- package/src/mcp.ts +10 -5
- package/src/providers.ts +84 -3
- package/src/site-admission.ts +39 -0
- package/src/site-probe.ts +43 -0
- package/src/source-enrichment.ts +1 -1
- package/src/source-verification.ts +15 -6
- package/src/types.ts +3 -2
- package/src/update-check.ts +39 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { atomicJson } from "./atomic-file.ts";
|
|
3
|
+
import { crawlSite, sitePostingsToJobs, type SiteSeed } from "./jobposting-site.ts";
|
|
4
|
+
|
|
5
|
+
export interface SiteProbeOptions { limit?: number; sample?: number; concurrency?: number; maxPages?: number; delayMs?: number; timeoutMs?: number }
|
|
6
|
+
export interface SiteProbeReport {
|
|
7
|
+
generatedAt: string; seeds: number; checked: number; withCareersPage: number; withPostings: number; robotsBlocked: number;
|
|
8
|
+
postings: number; indiaPostings: number; withDate: number; withIdentifier: number; pagesFetched: number;
|
|
9
|
+
sites: Array<{ companyName: string; companyDomain: string; careerUrl?: string; candidatePages: number; pagesFetched: number; postings: number; indiaPostings: number; robotsBlocked: boolean; issues: number }>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Bounded yield measurement over company seeds: how many company sites publish JobPosting markup, and how many roles that is. */
|
|
13
|
+
export async function probeSites(inputPath: string, reportPath: string, options: SiteProbeOptions = {}): Promise<SiteProbeReport> {
|
|
14
|
+
const all = JSON.parse(await readFile(inputPath, "utf8")) as SiteSeed[];
|
|
15
|
+
const byDomain = new Map<string, SiteSeed>();
|
|
16
|
+
for (const seed of all) if (seed.companyDomain && !byDomain.has(seed.companyDomain)) byDomain.set(seed.companyDomain, seed);
|
|
17
|
+
let seeds = [...byDomain.values()];
|
|
18
|
+
if (options.sample && options.sample < seeds.length) { let state = 12345; const random = () => (state = (state * 1103515245 + 12345) % 2147483648) / 2147483648; seeds = [...seeds].sort(() => random() - 0.5).slice(0, options.sample); }
|
|
19
|
+
if (options.limit) seeds = seeds.slice(0, options.limit);
|
|
20
|
+
const report: SiteProbeReport = { generatedAt: new Date().toISOString(), seeds: all.length, checked: 0, withCareersPage: 0, withPostings: 0, robotsBlocked: 0, postings: 0, indiaPostings: 0, withDate: 0, withIdentifier: 0, pagesFetched: 0, sites: [] };
|
|
21
|
+
let cursor = 0;
|
|
22
|
+
async function worker() {
|
|
23
|
+
while (cursor < seeds.length) {
|
|
24
|
+
const seed = seeds[cursor++]!;
|
|
25
|
+
const result = await crawlSite(seed, { maxPages: options.maxPages, delayMs: options.delayMs ?? 300, timeoutMs: options.timeoutMs }).catch((error) => ({ robotsBlocked: false, candidatePages: 0, pagesFetched: 0, postings: [], issues: [String(error)], careerUrl: undefined }));
|
|
26
|
+
const jobs = sitePostingsToJobs({ slug: seed.companyDomain, name: seed.companyName }, result.postings);
|
|
27
|
+
const india = jobs.filter((job) => job.eligibleCountries.includes("IN")).length;
|
|
28
|
+
report.checked += 1;
|
|
29
|
+
if (result.careerUrl) report.withCareersPage += 1;
|
|
30
|
+
if (result.postings.length) report.withPostings += 1;
|
|
31
|
+
if (result.robotsBlocked) report.robotsBlocked += 1;
|
|
32
|
+
report.postings += result.postings.length; report.indiaPostings += india; report.pagesFetched += result.pagesFetched;
|
|
33
|
+
report.withDate += result.postings.filter((posting) => posting.datePosted).length;
|
|
34
|
+
report.withIdentifier += result.postings.filter((posting) => posting.identifier).length;
|
|
35
|
+
report.sites.push({ companyName: seed.companyName, companyDomain: seed.companyDomain, careerUrl: result.careerUrl, candidatePages: result.candidatePages, pagesFetched: result.pagesFetched, postings: result.postings.length, indiaPostings: india, robotsBlocked: result.robotsBlocked, issues: result.issues.length });
|
|
36
|
+
if (report.checked % 25 === 0) await atomicJson(reportPath, report);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
await Promise.all(Array.from({ length: Math.max(1, options.concurrency ?? 6) }, worker));
|
|
40
|
+
report.sites.sort((left, right) => right.postings - left.postings);
|
|
41
|
+
await atomicJson(reportPath, report);
|
|
42
|
+
return report;
|
|
43
|
+
}
|
package/src/source-enrichment.ts
CHANGED
|
@@ -84,7 +84,7 @@ function isVerifiedCatalogRecord(company: unknown): boolean {
|
|
|
84
84
|
&& verification.canonicalSourceUrl === source.canonicalSourceUrl
|
|
85
85
|
&& typeof verification.checkedAt === "string" && Number.isFinite(Date.parse(verification.checkedAt))
|
|
86
86
|
&& typeof verification.observedCompanyName === "string" && verification.observedCompanyName.length > 0
|
|
87
|
-
&& ["provider_company_name", "provider_tenant", "structured_domain_link", "company_redirect", "company_page_link"].includes(String(verification.identityEvidence))
|
|
87
|
+
&& ["provider_company_name", "provider_tenant", "structured_domain_link", "company_redirect", "company_page_link", "provider_board", "company_site"].includes(String(verification.identityEvidence))
|
|
88
88
|
&& typeof verification.contentType === "string" && typeof verification.payloadVersion === "string" && verification.payloadVersion.length > 0
|
|
89
89
|
&& Number.isInteger(verification.jobCount) && Number(verification.jobCount) > 0;
|
|
90
90
|
}
|
|
@@ -58,7 +58,7 @@ export async function verifyCandidates(candidates: SourceCandidate[], options: V
|
|
|
58
58
|
const providerFetch = providerCooldowns.get(source.ats)!.wrap(fetcher);
|
|
59
59
|
try {
|
|
60
60
|
const evidence = await probe(candidate, source, providerFetch, timeoutMs, options.resolveHost, options.headTransport, options.pageTransport);
|
|
61
|
-
const replayed = evidence.identityEvidence === "company_redirect" || evidence.identityEvidence === "company_page_link";
|
|
61
|
+
const replayed = evidence.identityEvidence === "company_redirect" || evidence.identityEvidence === "company_page_link" || evidence.providerLinksDomain;
|
|
62
62
|
if (!replayed && !identityMatches(candidate.companyName, candidate.companyDomain, evidence.observedCompanyName, source.token)) {
|
|
63
63
|
rejected.push({ index, value: rejection(candidate, "identity_mismatch", `Expected ${candidate.companyName}; observed ${evidence.observedCompanyName}`) });
|
|
64
64
|
continue;
|
|
@@ -164,23 +164,32 @@ async function probe(candidate: SourceCandidate, source: ResolvedSource, fetcher
|
|
|
164
164
|
throw new VerificationError("unreachable", `HTTP ${response.status}`);
|
|
165
165
|
}
|
|
166
166
|
const contentType = response.headers.get("content-type") ?? "unknown";
|
|
167
|
-
let body: unknown;
|
|
168
|
-
try { body = await response.json(); } catch { throw new VerificationError("invalid_payload", "Endpoint did not return JSON"); }
|
|
169
167
|
const spec = providerSpec(source.ats);
|
|
168
|
+
let body: unknown;
|
|
169
|
+
try { body = spec?.bodyFormat === "text" ? await response.text() : await response.json(); } catch { throw new VerificationError("invalid_payload", "Endpoint did not return JSON"); }
|
|
170
170
|
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");
|
|
171
171
|
if (!jobs) throw new VerificationError("invalid_payload", "Payload does not contain the expected jobs array");
|
|
172
172
|
if (jobs.length === 0) throw new VerificationError("empty_board", "Source has no jobs, so identity cannot be verified");
|
|
173
|
-
|
|
173
|
+
let providerName = spec ? spec.providerName(jobs, body) : source.ats === "greenhouse" ? majority(jobs.map((job) => stringField(job, "company_name")).filter(Boolean)) : source.ats === "workday" ? workday!.tenant : "";
|
|
174
|
+
let infoWebsite = "";
|
|
175
|
+
if (spec?.companyInfo) {
|
|
176
|
+
const info = await spec.companyInfo(source.token, async (url, format = "json") => { const reply = await fetchProbeWithRetry(fetcher, url, { signal: controller.signal }); if (!reply.ok) { await reply.body?.cancel().catch(() => undefined); throw new VerificationError("unreachable", `HTTP ${reply.status}`); } return format === "text" ? reply.text() : reply.json(); }).catch((): { name: string; website?: string } => ({ name: "" }));
|
|
177
|
+
providerName = providerName || info.name; infoWebsite = info.website ?? "";
|
|
178
|
+
}
|
|
179
|
+
const expectedDomain = candidate.companyDomain.toLowerCase().replace(/^www\./, "");
|
|
180
|
+
// The provider's own tenant record names the company website: that is the company's claim on its own board, stronger than any name heuristic.
|
|
181
|
+
const providerLinksDomain = Boolean(infoWebsite) && (infoWebsite === expectedDomain || infoWebsite.endsWith(`.${expectedDomain}`));
|
|
174
182
|
const tenantMatches = source.ats === "workday" && identityMatches(candidate.companyName, candidate.companyDomain, workday!.tenant, source.token);
|
|
175
183
|
const namedProvider = source.ats === "greenhouse" || tenantMatches || Boolean(spec && providerName);
|
|
176
|
-
const hasDomainLink = !namedProvider && structuredIdentityLinksDomain(jobs, candidate.companyDomain);
|
|
184
|
+
const hasDomainLink = providerLinksDomain || !namedProvider && structuredIdentityLinksDomain(jobs, candidate.companyDomain);
|
|
177
185
|
const hasRedirectEvidence = !namedProvider && await verifiedCompanyRedirect(candidate, source, resolveHost, headTransport, timeoutMs);
|
|
178
186
|
const hasPageLink = !namedProvider && !hasDomainLink && !hasRedirectEvidence && await verifiedCompanyPageLink(candidate, source, resolveHost, pageTransport, timeoutMs);
|
|
179
187
|
if (!namedProvider && !hasDomainLink && !hasRedirectEvidence && !hasPageLink) throw new VerificationError("identity_mismatch", `Neither structured identity fields, a verified company redirect, nor a company careers-page link point to ${candidate.companyDomain}`);
|
|
180
188
|
const observedCompanyName = namedProvider && providerName ? providerName : candidate.companyName;
|
|
181
189
|
return {
|
|
182
190
|
observedCompanyName,
|
|
183
|
-
|
|
191
|
+
providerLinksDomain,
|
|
192
|
+
identityEvidence: providerLinksDomain ? "structured_domain_link" as const : tenantMatches ? "provider_tenant" as const : (namedProvider && providerName ? "provider_company_name" as const : hasDomainLink ? "structured_domain_link" as const : hasRedirectEvidence ? "company_redirect" as const : "company_page_link" as const),
|
|
184
193
|
contentType,
|
|
185
194
|
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"}`,
|
|
186
195
|
jobCount: source.ats === "workday" && isRecord(body) && typeof body.total === "number" ? body.total : jobs.length,
|
package/src/types.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
/** ATS boards plus "jobposting": a company's own career site read only through its schema.org JobPosting markup (token = careers URL). */
|
|
2
|
+
export const ALL_PROVIDERS = ["greenhouse", "lever", "ashby", "workday", "recruitee", "smartrecruiters", "workable", "breezy", "freshteam", "keka", "zohorecruit", "jobposting"] as const;
|
|
2
3
|
export type Ats = (typeof ALL_PROVIDERS)[number];
|
|
3
4
|
|
|
4
5
|
export interface DomainEvidence {
|
|
@@ -40,7 +41,7 @@ export interface SourceVerification {
|
|
|
40
41
|
checkedAt: string;
|
|
41
42
|
canonicalSourceUrl: string;
|
|
42
43
|
observedCompanyName: string;
|
|
43
|
-
identityEvidence: "provider_company_name" | "provider_tenant" | "structured_domain_link" | "company_redirect" | "company_page_link" | "provider_board";
|
|
44
|
+
identityEvidence: "provider_company_name" | "provider_tenant" | "structured_domain_link" | "company_redirect" | "company_page_link" | "provider_board" | "company_site";
|
|
44
45
|
contentType: string;
|
|
45
46
|
payloadVersion: string;
|
|
46
47
|
jobCount: number;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { VERSION } from "./version.ts";
|
|
2
|
+
|
|
3
|
+
export interface UpdateNotice { installed: string; latest: string; run: string; message: string }
|
|
4
|
+
type Fetch = (input: string, init?: RequestInit) => Promise<Response>;
|
|
5
|
+
|
|
6
|
+
export const UPDATE_COMMAND = "bun add --global openings";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One small request to the npm registry at startup, never blocking a tool call. The result rides on every tool
|
|
10
|
+
* response so the AI app can prompt the person to update; the server itself never modifies the install.
|
|
11
|
+
*/
|
|
12
|
+
export function startUpdateCheck(options: { current?: string; fetcher?: Fetch; timeoutMs?: number; enabled?: boolean } = {}): { get(): UpdateNotice | null; ready: Promise<void> } {
|
|
13
|
+
const current = options.current ?? VERSION;
|
|
14
|
+
let notice: UpdateNotice | null = null;
|
|
15
|
+
const ready = options.enabled === false ? Promise.resolve() : (async () => {
|
|
16
|
+
try {
|
|
17
|
+
const response = await (options.fetcher ?? globalThis.fetch)("https://registry.npmjs.org/openings/latest", { signal: AbortSignal.timeout(options.timeoutMs ?? 5_000), headers: { accept: "application/json" } });
|
|
18
|
+
if (!response.ok) return;
|
|
19
|
+
const body = await response.json() as { version?: unknown };
|
|
20
|
+
if (typeof body.version === "string" && isNewer(body.version, current)) notice = updateNotice(current, body.version);
|
|
21
|
+
} catch { /* offline or blocked: no notice */ }
|
|
22
|
+
})();
|
|
23
|
+
return { get: () => notice, ready };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function updateNotice(installed: string, latest: string): UpdateNotice {
|
|
27
|
+
return { installed, latest, run: UPDATE_COMMAND, message: `Openings ${latest} is available; ${installed} is installed. Tell the person to run "${UPDATE_COMMAND}" and restart their AI app to get fixes and new coverage.` };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Numeric dot-version comparison; pre-release suffixes are ignored. */
|
|
31
|
+
export function isNewer(candidate: string, current: string): boolean {
|
|
32
|
+
const parse = (value: string) => value.split(".").map((part) => Number.parseInt(part, 10) || 0);
|
|
33
|
+
const [left, right] = [parse(candidate), parse(current)];
|
|
34
|
+
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
|
|
35
|
+
const difference = (left[index] ?? 0) - (right[index] ?? 0);
|
|
36
|
+
if (difference !== 0) return difference > 0;
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.1.
|
|
1
|
+
export const VERSION = "0.1.19";
|