JB/web/src/lib/sources/adapters/ashby.ts

117 lines
3.8 KiB
TypeScript

/**
* Ashby ATS Adapter
*
* Interacts with public Ashby job board posting APIs:
* https://api.ashbyhq.com/posting-api/job-board/{board_token}
*/
import { IJobSourceAdapter, DiscoveryResult, SourceHealth, RateLimitPolicy, NormalizedJobRecord } from "../types";
import { generateContentFingerprint, normalizeJobUrl, classifyExperienceLevel } from "../deduplication";
export class AshbySourceAdapter implements IJobSourceAdapter {
readonly id: string;
readonly name: string;
readonly sourceType = "ATS" as const;
readonly provider = "ashby" as const;
private boardToken: string;
constructor(boardToken: string, companyName?: string) {
this.boardToken = boardToken.toLowerCase().trim();
this.name = companyName || `Ashby (${boardToken})`;
this.id = `ashby_${this.boardToken}`;
}
async healthCheck(): Promise<SourceHealth> {
const start = Date.now();
try {
const url = `https://api.ashbyhq.com/posting-api/job-board/${this.boardToken}`;
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.ashbyhq.com/posting-api/job-board/${this.boardToken}`;
const res = await fetch(url, { headers: { "User-Agent": "JobsBoard-Acquisition/1.0" } });
if (!res.ok) {
throw new Error(`Ashby API error HTTP ${res.status}: ${res.statusText}`);
}
const data = await res.json();
const rawList = data.jobs || [];
for (const item of rawList.slice(0, limit)) {
try {
const title = (item.title || "").trim();
const rawJobUrl = item.jobUrl || `https://jobs.ashbyhq.com/${this.boardToken}/${item.id}`;
if (!title) continue;
const jobUrl = normalizeJobUrl(rawJobUrl);
const location = item.location || (item.isRemote ? "Remote, USA" : "United States");
const isRemote = !!item.isRemote;
const deptName = item.department || "Software & Engineering";
const cleanDesc = (item.descriptionPlain || item.descriptionHtml || "")
.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 Ashby.`,
jobUrl,
datePosted: item.publishedAt ? new Date(item.publishedAt) : new Date(),
source: "ashby",
sourceId: this.id,
fingerprintHash: fingerprint,
rawPayload: item,
});
} catch (itemErr: any) {
errors.push(`Failed to parse Ashby 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,
};
}
}