openings 0.1.6 → 0.1.7
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 +11684 -94
- package/package.json +2 -2
- package/src/board-verification.ts +13 -8
- package/src/cli.ts +3 -2
- package/src/common-crawl-discovery.ts +1 -1
- package/src/mcp.ts +1 -0
- package/src/providers.ts +35 -1
- package/src/types.ts +1 -1
- package/src/version.ts +1 -1
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openings",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
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", "smartrecruiters", "workable", "breezy"],
|
|
9
|
+
"keywords": ["jobs", "resume", "matching", "mcp", "greenhouse", "lever", "ashby", "workday", "recruitee", "smartrecruiters", "workable", "breezy", "freshteam"],
|
|
10
10
|
"type": "module",
|
|
11
11
|
"bin": { "openings-mcp": "./src/package-mcp.ts" },
|
|
12
12
|
"exports": "./src/index.ts",
|
|
@@ -7,8 +7,8 @@ import type { Ats, Company, SourceVerification } from "./types.ts";
|
|
|
7
7
|
|
|
8
8
|
type Fetch = (input: string | URL, init?: RequestInit) => Promise<Response>;
|
|
9
9
|
|
|
10
|
-
/** Providers whose public board is accepted as identity on its own. Workday
|
|
11
|
-
export const BOARD_TIER_PROVIDERS: ReadonlySet<Ats> = new Set(["greenhouse", "lever", "ashby", "recruitee", "smartrecruiters", "workable", "breezy"]);
|
|
10
|
+
/** Providers whose public board is accepted as identity on its own. Workday boards are identified by tenant; their crawls are heavier but they carry the large employers. */
|
|
11
|
+
export const BOARD_TIER_PROVIDERS: ReadonlySet<Ats> = new Set(["greenhouse", "lever", "ashby", "recruitee", "smartrecruiters", "workable", "breezy", "workday", "freshteam"]);
|
|
12
12
|
|
|
13
13
|
export interface BoardVerificationOptions {
|
|
14
14
|
fetch?: Fetch;
|
|
@@ -124,17 +124,21 @@ async function probeBoard(lead: EnrichmentLead, fetcher: Fetch, timeoutMs: numbe
|
|
|
124
124
|
const controller = new AbortController();
|
|
125
125
|
const timer = setTimeout(() => controller.abort(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
126
126
|
try {
|
|
127
|
-
const
|
|
127
|
+
const init: RequestInit = source.ats === "workday"
|
|
128
|
+
? { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ appliedFacets: {}, limit: 20, offset: 0, searchText: "" }), signal: controller.signal }
|
|
129
|
+
: { signal: controller.signal };
|
|
130
|
+
const response = await fetcher(source.structuredEndpoint, init);
|
|
128
131
|
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
132
|
let body: unknown;
|
|
130
133
|
try { body = await response.json(); } catch { throw new BoardError("invalid_payload", "Endpoint did not return JSON"); }
|
|
131
134
|
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);
|
|
135
|
+
const jobs = spec ? spec.jobsFromBody(body) : source.ats === "lever" ? asArray(body) : asArray(isRecord(body) ? body[source.ats === "recruitee" ? "offers" : source.ats === "workday" ? "jobPostings" : "jobs"] : undefined);
|
|
133
136
|
if (!jobs) throw new BoardError("invalid_payload", "Payload does not contain the expected jobs array");
|
|
134
137
|
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
|
-
|
|
138
|
+
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)) : source.ats === "workday" ? humanize(source.token.split("/")[1] ?? source.token) : "";
|
|
139
|
+
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" : source.ats === "workday" ? "workday-cxs:v1" : `ashby-job-board:${isRecord(body) && typeof body.apiVersion === "string" ? body.apiVersion : "unknown"}`;
|
|
140
|
+
const jobCount = source.ats === "workday" && isRecord(body) && typeof body.total === "number" ? body.total : jobs.length;
|
|
141
|
+
return { providerName, contentType: response.headers.get("content-type") ?? "unknown", payloadVersion, jobCount };
|
|
138
142
|
} finally { clearTimeout(timer); }
|
|
139
143
|
}
|
|
140
144
|
|
|
@@ -152,7 +156,8 @@ function withAttempt(lead: EnrichmentLead, attempt: LeadAttempt): EnrichmentLead
|
|
|
152
156
|
}
|
|
153
157
|
|
|
154
158
|
function uniqueSlug(lead: EnrichmentLead, used: Set<string>): string | null {
|
|
155
|
-
const
|
|
159
|
+
const raw = lead.ats === "workday" ? (lead.token.split("/")[1] ?? lead.token) : lead.token;
|
|
160
|
+
const base = raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || lead.ats;
|
|
156
161
|
for (const candidate of [base, `${lead.ats}-${base}`]) if (!used.has(candidate)) return candidate;
|
|
157
162
|
return null;
|
|
158
163
|
}
|
package/src/cli.ts
CHANGED
|
@@ -343,12 +343,13 @@ function parseCommonCrawlDiscovery(args: string[]) {
|
|
|
343
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"; }
|
|
344
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; }
|
|
345
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; }
|
|
346
|
-
else if (arg === "--index-record-limit") { indexRecordLimit = Number(args[++index]); if (!Number.isInteger(indexRecordLimit) || indexRecordLimit < 1 || indexRecordLimit >
|
|
346
|
+
else if (arg === "--index-record-limit") { indexRecordLimit = Number(args[++index]); if (!Number.isInteger(indexRecordLimit) || indexRecordLimit < 1 || indexRecordLimit > 100_000) return "--index-record-limit must be an integer from 1 to 100000"; }
|
|
347
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"; }
|
|
348
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()); }
|
|
349
349
|
else return `Unknown option: ${arg}`;
|
|
350
350
|
}
|
|
351
|
-
if ((
|
|
351
|
+
if ((sampleTokenLimit !== undefined || excludeTokens.length || registryPath === undefined) && provider !== "recruitee") return "bounded report-only discovery requires --provider recruitee";
|
|
352
|
+
if (provider === "recruitee" && indexRecordLimit !== undefined && indexRecordLimit > 2_000) return "--index-record-limit must be an integer from 1 to 2000 for --provider recruitee";
|
|
352
353
|
if (provider === "recruitee" && (!indexUrl || indexRecordLimit === undefined || sampleTokenLimit === undefined || !reportOnly)) return "--provider recruitee requires --index-url, --index-record-limit, --sample-token-limit, and --report-only";
|
|
353
354
|
if (provider === "recruitee" && !underOpenings(report)) return "bounded Recruitee discovery --report must be under .openings";
|
|
354
355
|
if (sampleTokenLimit !== undefined && indexRecordLimit !== undefined && sampleTokenLimit > indexRecordLimit) return "--sample-token-limit cannot exceed --index-record-limit";
|
|
@@ -39,7 +39,7 @@ export interface CommonCrawlDiscoveryReport extends ReportMeta {
|
|
|
39
39
|
|
|
40
40
|
const providerPatterns: Record<Ats, string[]> = {
|
|
41
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[]>,
|
|
42
|
+
...Object.fromEntries(PROVIDERS.map((spec) => [spec.ats, spec.crawlPatterns])) as Record<"smartrecruiters" | "workable" | "breezy" | "freshteam", string[]>,
|
|
43
43
|
};
|
|
44
44
|
const patterns = Object.values(providerPatterns).flat();
|
|
45
45
|
const recordsPerPattern = 10_000;
|
package/src/mcp.ts
CHANGED
package/src/providers.ts
CHANGED
|
@@ -145,7 +145,41 @@ const breezy: ProviderSpec = {
|
|
|
145
145
|
},
|
|
146
146
|
};
|
|
147
147
|
|
|
148
|
-
|
|
148
|
+
const freshteam: ProviderSpec = {
|
|
149
|
+
ats: "freshteam",
|
|
150
|
+
label: "Freshteam",
|
|
151
|
+
hosts: ["freshteam.com"],
|
|
152
|
+
crawlPatterns: ["*.freshteam.com/jobs*"],
|
|
153
|
+
resolve(url) {
|
|
154
|
+
const match = /^([a-z0-9-]+)\.freshteam\.com$/i.exec(url.hostname);
|
|
155
|
+
return match && !["www", "app", "api", "support", "help", "blog"].includes(match[1]!.toLowerCase()) ? match[1]!.toLowerCase() : null;
|
|
156
|
+
},
|
|
157
|
+
canonicalUrl: (token) => `https://${token}.freshteam.com/jobs`,
|
|
158
|
+
endpoint: (token) => `https://${token}.freshteam.com/hire/widgets/jobs.json`,
|
|
159
|
+
jobsFromBody(body) {
|
|
160
|
+
if (!isRecord(body)) return null;
|
|
161
|
+
const jobs = asRecords(body.jobs);
|
|
162
|
+
if (!jobs) return null;
|
|
163
|
+
const branches = new Map((asRecords(body.branches) ?? []).map((branch) => [String(branch.id), branch]));
|
|
164
|
+
// The widget lists branches separately; pin each job's branch onto the record so normalize() sees it.
|
|
165
|
+
return jobs.filter((job) => job.deleted !== true).map((job) => ({ ...job, branch: branches.get(String(job.branch_id)) }));
|
|
166
|
+
},
|
|
167
|
+
providerName: () => "",
|
|
168
|
+
payloadVersion: () => "freshteam-widget:v1",
|
|
169
|
+
normalize(company, job) {
|
|
170
|
+
const branch = isRecord(job.branch) ? job.branch : {};
|
|
171
|
+
const location = [str(branch.city), str(branch.state), str(branch.country_code).toUpperCase()].filter(Boolean).join(", ") || (job.remote === true ? "Remote" : "Unspecified");
|
|
172
|
+
const remote = job.remote === true;
|
|
173
|
+
return classifyJob({
|
|
174
|
+
id: `freshteam:${company.slug}:${str(job.unique_id) || str(job.id)}`, company: company.name, title: str(job.title), location,
|
|
175
|
+
remote, workMode: remote ? "remote" : "unknown",
|
|
176
|
+
eligibleCountries: [], excludedCountries: [], eligibleRegions: [], eligibilityConfidence: "unknown",
|
|
177
|
+
url: `https://${company.token}.freshteam.com/jobs/${encodeURIComponent(str(job.unique_id) || str(job.id))}`, updatedAt: str(job.created_at) || undefined, description: plainText(str(job.description)),
|
|
178
|
+
});
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
export const PROVIDERS: ReadonlyArray<ProviderSpec> = [smartrecruiters, workable, breezy, freshteam];
|
|
149
183
|
|
|
150
184
|
export function providerSpec(ats: string): ProviderSpec | undefined {
|
|
151
185
|
return PROVIDERS.find((spec) => spec.ats === ats);
|
package/src/types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const ALL_PROVIDERS = ["greenhouse", "lever", "ashby", "workday", "recruitee", "smartrecruiters", "workable", "breezy"] as const;
|
|
1
|
+
export const ALL_PROVIDERS = ["greenhouse", "lever", "ashby", "workday", "recruitee", "smartrecruiters", "workable", "breezy", "freshteam"] as const;
|
|
2
2
|
export type Ats = (typeof ALL_PROVIDERS)[number];
|
|
3
3
|
|
|
4
4
|
export interface DomainEvidence {
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.1.
|
|
1
|
+
export const VERSION = "0.1.7";
|