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/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "openings",
3
- "version": "0.1.6",
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 stays on the company-identity path because its crawls are expensive. */
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 response = await fetcher(source.structuredEndpoint, { signal: controller.signal });
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
- return { providerName, contentType: response.headers.get("content-type") ?? "unknown", payloadVersion, jobCount: jobs.length };
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 base = lead.token.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || lead.ats;
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 > 2_000) return "--index-record-limit must be an integer from 1 to 2000"; }
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 ((indexRecordLimit !== undefined || sampleTokenLimit !== undefined || excludeTokens.length || registryPath === undefined) && provider !== "recruitee") return "bounded report-only discovery requires --provider recruitee";
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
@@ -95,6 +95,7 @@ export async function serve() {
95
95
  }
96
96
  }
97
97
  }
98
+ await runtime.usage?.flush();
98
99
  }
99
100
 
100
101
  if (import.meta.main) await serve();
package/src/providers.ts CHANGED
@@ -145,7 +145,41 @@ const breezy: ProviderSpec = {
145
145
  },
146
146
  };
147
147
 
148
- export const PROVIDERS: ReadonlyArray<ProviderSpec> = [smartrecruiters, workable, breezy];
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.6";
1
+ export const VERSION = "0.1.7";