Jobber/extractors/lever/manifest.ts
ilia 7b3dfb002a
Some checks failed
CI / Linting (Biome) (push) Failing after 36s
CI / Tests (push) Successful in 5m54s
CI / Type Check (adzuna-extractor) (push) Successful in 1m6s
CI / Type Check (gradcracker-extractor) (push) Successful in 1m9s
CI / Type Check (hiringcafe-extractor) (push) Successful in 1m5s
CI / Type Check (orchestrator) (push) Successful in 1m21s
CI / Type Check (startupjobs-extractor) (push) Successful in 1m4s
CI / Type Check (ukvisajobs-extractor) (push) Successful in 1m4s
CI / Documentation (push) Successful in 1m52s
feat(extractors): add 17 job source extractors and cross-source dedup
Adds extractor packages: arbeitnow, ashby, careerjet, fourdayweek,
greenhouse, himalayas, jobicy, jooble, lever, reed, remoteok, remotive,
themuse, usajobs, weworkremotely, workday — each with manifest, package
metadata and README.

Pipeline / shared:
- shared/job-fingerprint: stable hash for cross-source dedup, with tests
- discover-jobs: dedup via fingerprint and richer per-source merging
- jobs repository: fingerprint-aware upsert / lookup
- settings-registry, settings types/routes, demo-defaults: knobs for the
  new sources
- shared extractors index: register the new manifests
- location-support, profiles route: small fixes for the new sources

Tooling:
- scripts/smoke-extractors.ts to sanity-check each source locally
- scripts/jobber-cron-{cherepaha,dobkin}.env.example: per-host cron
  templates (CHANGEME placeholders only)
- .env.example: documented env vars for the new extractors
- .gitignore: ignore extractors/*/storage/ runtime caches (was ukvisajobs only)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 20:17:52 -04:00

183 lines
5.3 KiB
TypeScript

/**
* Lever public job postings API.
*
* https://github.com/lever/postings-api/blob/master/README.md
* GET https://api.lever.co/v0/postings/{company}?mode=json
*
* No auth. We iterate `leverCompanies` (set in Settings or LEVER_COMPANIES env)
* and pull every active posting; downstream filtering by `searchTerms` /
* country happens in the pipeline.
*/
import type {
ExtractorManifest,
ExtractorRunResult,
} from "@shared/types/extractors";
import type { CreateJobInput } from "@shared/types/jobs";
interface LeverCategories {
team?: string;
department?: string;
commitment?: string;
location?: string;
allLocations?: string[];
}
interface LeverPosting {
id?: string;
text?: string;
hostedUrl?: string;
applyUrl?: string;
description?: string;
descriptionPlain?: string;
categories?: LeverCategories;
createdAt?: number;
workplaceType?: string;
}
function asString(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
function readCompanies(raw: string | undefined): string[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return parsed
.map((entry) =>
typeof entry === "string" ? entry.trim().toLowerCase() : "",
)
.filter((entry) => entry.length > 0);
}
} catch {
// fall through to delimited-list parsing below
}
return raw
.split(/[\n,;|]+/)
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean);
}
function locationFor(posting: LeverPosting): string | undefined {
const cats = posting.categories;
if (!cats) return undefined;
if (Array.isArray(cats.allLocations) && cats.allLocations.length > 0) {
return cats.allLocations.filter(Boolean).join("; ");
}
return asString(cats.location);
}
function mapPosting(
posting: LeverPosting,
company: string,
): CreateJobInput | null {
const jobUrl = asString(posting.hostedUrl);
if (!jobUrl) return null;
const employer = company
.split("-")
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
return {
source: "lever",
sourceJobId: asString(posting.id),
title: asString(posting.text) ?? "Unknown Title",
employer: employer || company,
jobUrl,
applicationLink: asString(posting.applyUrl) ?? jobUrl,
location: locationFor(posting),
jobType: asString(posting.categories?.commitment),
jobFunction: asString(posting.categories?.team),
companyIndustry: asString(posting.categories?.department),
isRemote:
posting.workplaceType?.toLowerCase() === "remote" ? true : undefined,
datePosted:
typeof posting.createdAt === "number"
? new Date(posting.createdAt).toISOString()
: undefined,
jobDescription:
asString(posting.descriptionPlain) ?? asString(posting.description),
};
}
async function fetchCompany(company: string): Promise<LeverPosting[]> {
const url = `https://api.lever.co/v0/postings/${encodeURIComponent(company)}?mode=json`;
const response = await fetch(url, {
headers: { Accept: "application/json" },
});
if (response.status === 404) return [];
if (!response.ok) {
throw new Error(
`Lever request for "${company}" failed with status ${response.status}`,
);
}
const body = (await response.json()) as unknown;
return Array.isArray(body) ? (body as LeverPosting[]) : [];
}
export const manifest: ExtractorManifest = {
id: "lever",
displayName: "Lever (ATS)",
providesSources: ["lever"],
async run(context): Promise<ExtractorRunResult> {
if (context.shouldCancel?.()) return { success: true, jobs: [] };
const companies = readCompanies(context.settings.leverCompanies);
if (companies.length === 0) {
return {
success: true,
jobs: [],
error:
"No Lever companies configured. Set LEVER_COMPANIES or the leverCompanies setting (comma- or newline-separated slugs).",
};
}
const seen = new Set<string>();
const out: CreateJobInput[] = [];
try {
for (let i = 0; i < companies.length; i += 1) {
if (context.shouldCancel?.()) break;
const company = companies[i];
context.onProgress?.({
phase: "list",
termsProcessed: i,
termsTotal: companies.length,
currentUrl: company,
detail: `Lever: ${company} (${i + 1}/${companies.length})`,
});
let added = 0;
const postings = await fetchCompany(company);
for (const posting of postings) {
const mapped = mapPosting(posting, company);
if (!mapped) continue;
const key = mapped.sourceJobId || mapped.jobUrl;
if (seen.has(key)) continue;
seen.add(key);
out.push(mapped);
added += 1;
}
context.onProgress?.({
phase: "list",
termsProcessed: i + 1,
termsTotal: companies.length,
currentUrl: company,
jobPagesProcessed: out.length,
detail: `Lever: ${company}${added} jobs (${out.length} total)`,
});
}
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return { success: false, jobs: out, error: message };
}
return { success: true, jobs: out };
},
};
export default manifest;