117 lines
3.8 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|