125 lines
4.1 KiB
TypeScript
125 lines
4.1 KiB
TypeScript
/**
|
|
* Lever ATS Adapter
|
|
*
|
|
* Interacts with public Lever job board APIs:
|
|
* https://api.lever.co/v0/postings/{board_token}?mode=json
|
|
*/
|
|
|
|
import { IJobSourceAdapter, DiscoveryResult, SourceHealth, RateLimitPolicy, NormalizedJobRecord } from "../types";
|
|
import { generateContentFingerprint, normalizeJobUrl, classifyExperienceLevel } from "../deduplication";
|
|
|
|
export class LeverSourceAdapter implements IJobSourceAdapter {
|
|
readonly id: string;
|
|
readonly name: string;
|
|
readonly sourceType = "ATS" as const;
|
|
readonly provider = "lever" as const;
|
|
private boardToken: string;
|
|
|
|
constructor(boardToken: string, companyName?: string) {
|
|
this.boardToken = boardToken.toLowerCase().trim();
|
|
this.name = companyName || `Lever (${boardToken})`;
|
|
this.id = `lever_${this.boardToken}`;
|
|
}
|
|
|
|
async healthCheck(): Promise<SourceHealth> {
|
|
const start = Date.now();
|
|
try {
|
|
const url = `https://api.lever.co/v0/postings/${this.boardToken}?mode=json`;
|
|
const res = await fetch(url, { headers: { "User-Agent": "JobsBoard-Acquisition/1.0" } });
|
|
return {
|
|
healthy: res.ok,
|
|
statusCode: res.status,
|
|
latencyMs: Date.now() - start,
|
|
errorMessage: res.ok ? undefined : `HTTP ${res.status}: ${res.statusText}`,
|
|
};
|
|
} catch (err: any) {
|
|
return {
|
|
healthy: false,
|
|
latencyMs: Date.now() - start,
|
|
errorMessage: err.message,
|
|
};
|
|
}
|
|
}
|
|
|
|
getRateLimitPolicy(): RateLimitPolicy {
|
|
return {
|
|
requestsPerMinute: 60,
|
|
burstLimit: 10,
|
|
retryAfterMs: 2000,
|
|
};
|
|
}
|
|
|
|
async discoverJobs(options: { limit?: number } = {}): Promise<DiscoveryResult> {
|
|
const limit = options.limit || 50;
|
|
const errors: string[] = [];
|
|
const normalizedJobs: NormalizedJobRecord[] = [];
|
|
|
|
try {
|
|
const url = `https://api.lever.co/v0/postings/${this.boardToken}?mode=json`;
|
|
const res = await fetch(url, { headers: { "User-Agent": "JobsBoard-Acquisition/1.0" } });
|
|
|
|
if (!res.ok) {
|
|
throw new Error(`Lever API error HTTP ${res.status}: ${res.statusText}`);
|
|
}
|
|
|
|
const rawList = await res.json();
|
|
if (!Array.isArray(rawList)) {
|
|
throw new Error(`Unexpected Lever API response format (expected array)`);
|
|
}
|
|
|
|
for (const item of rawList.slice(0, limit)) {
|
|
try {
|
|
const title = (item.text || "").trim();
|
|
const rawJobUrl = item.hostedUrl || "";
|
|
if (!title || !rawJobUrl) continue;
|
|
|
|
const jobUrl = normalizeJobUrl(rawJobUrl);
|
|
const categories = item.categories || {};
|
|
const location = categories.location || "Remote, USA";
|
|
const workplaceType = categories.workplaceType || "";
|
|
const isRemote =
|
|
workplaceType.toLowerCase().includes("remote") ||
|
|
location.toLowerCase().includes("remote") ||
|
|
title.toLowerCase().includes("remote");
|
|
|
|
const deptName = categories.department || "Software & Engineering";
|
|
|
|
const cleanDesc = (item.descriptionPlain || item.description || "")
|
|
.replace(/<[^>]+>/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
|
|
const fingerprint = generateContentFingerprint(this.name, title, location, isRemote);
|
|
|
|
normalizedJobs.push({
|
|
title,
|
|
company: this.name,
|
|
location,
|
|
isRemote,
|
|
department: deptName,
|
|
experienceLevel: classifyExperienceLevel(title),
|
|
description: cleanDesc.slice(0, 5000) || `Position at ${this.name}. Apply on Lever.`,
|
|
jobUrl,
|
|
datePosted: item.createdAt ? new Date(item.createdAt) : new Date(),
|
|
source: "lever",
|
|
sourceId: this.id,
|
|
fingerprintHash: fingerprint,
|
|
rawPayload: item,
|
|
});
|
|
} catch (itemErr: any) {
|
|
errors.push(`Failed to parse Lever job ${item.id}: ${itemErr.message}`);
|
|
}
|
|
}
|
|
} catch (err: any) {
|
|
errors.push(`Discovery failed for ${this.id}: ${err.message}`);
|
|
}
|
|
|
|
return {
|
|
sourceId: this.id,
|
|
provider: this.provider,
|
|
totalDiscovered: normalizedJobs.length,
|
|
jobs: normalizedJobs,
|
|
errors,
|
|
};
|
|
}
|
|
}
|