DEV Community

Felipe Rohde
Felipe Rohde

Posted on

Pulling jobs straight from company career pages with TypeScript (Greenhouse, Lever, Ashby)

this week I posted on X about Career Hound, a job board run by Roman Lakhnyuk. per the Starter Story interview, it pulls job listings straight from company websites and charges behind a hard paywall, $7 a week, $20 a month or $50 a year. Roman doesn't code, and the dashboard he showed had $12K MRR

the business side is a distribution story. but the dev in me got stuck on the boring part: how do you actually pull jobs "straight from company websites" without writing a scraper per company?

I don't know how Career Hound does it, and this isn't their code. but I know one cheap way to do it, because a lot of companies don't host their own careers page. they use an applicant tracking system (ATS) like Greenhouse, Lever or Ashby, and all three expose a public, unauthenticated JSON endpoint for the job board. no HTML parsing, no headless browser

so this is a small TypeScript job sync that reads those three, normalizes them into one shape and tells you what's new since the last run. Node 20+, zero dependencies, I ran everything below with npx tsx

the three endpoints

  • Greenhouse: https://boards-api.greenhouse.io/v1/boards/{slug}/jobs
  • Lever: https://api.lever.co/v0/postings/{slug}?mode=json
  • Ashby: https://api.ashbyhq.com/posting-api/job-board/{slug}

the slug is just the company name in their hosted board URL. job-boards.greenhouse.io/vercel means the slug is vercel. jobs.ashbyhq.com/ashby means ashby. you can paste these in the browser and see the raw JSON

each one returns a different shape, which is the annoying part. Greenhouse gives you title, location.name, absolute_url. Lever gives you text for the title, categories.location, hostedUrl and createdAt as a unix ms number. Ashby gives you title, location, isRemote, jobUrl. so step one is one type to rule them

one shape for every job

// jobs.mts
type Job = {
  id: string;
  source: "greenhouse" | "lever" | "ashby";
  company: string;
  title: string;
  location: string;
  remote: boolean;
  url: string;
  postedAt: string | null;
};

type Board = { ats: Job["source"]; slug: string };

async function getJSON<T>(url: string): Promise<T> {
  const res = await fetch(url, { headers: { "user-agent": "jobs-sync/0.1" } });
  if (!res.ok) throw new Error(`${res.status} on ${url}`);
  return res.json() as Promise<T>;
}
Enter fullscreen mode Exit fullscreen mode

I prefix ids with the source (gh:, lv:, ab:) so two ATSs can never collide. that matters later for the diff

one adapter per ATS

async function fromGreenhouse(slug: string): Promise<Job[]> {
  const data = await getJSON<{ jobs: any[] }>(
    `https://boards-api.greenhouse.io/v1/boards/${slug}/jobs`
  );
  return data.jobs.map((j) => ({
    id: `gh:${j.id}`,
    source: "greenhouse",
    company: j.company_name ?? slug,
    title: j.title,
    location: j.location?.name ?? "",
    remote: /remote/i.test(j.location?.name ?? ""),
    url: j.absolute_url,
    postedAt: j.first_published ?? j.updated_at ?? null,
  }));
}

async function fromLever(slug: string): Promise<Job[]> {
  const data = await getJSON<any[]>(
    `https://api.lever.co/v0/postings/${slug}?mode=json`
  );
  return data.map((j) => ({
    id: `lv:${j.id}`,
    source: "lever",
    company: slug,
    title: j.text,
    location: j.categories?.location ?? "",
    remote: j.workplaceType === "remote",
    url: j.hostedUrl,
    postedAt: j.createdAt ? new Date(j.createdAt).toISOString() : null,
  }));
}

async function fromAshby(slug: string): Promise<Job[]> {
  const data = await getJSON<{ jobs: any[] }>(
    `https://api.ashbyhq.com/posting-api/job-board/${slug}`
  );
  return data.jobs
    .filter((j) => j.isListed !== false)
    .map((j) => ({
      id: `ab:${j.id}`,
      source: "ashby",
      company: slug,
      title: j.title,
      location: j.location ?? "",
      remote: Boolean(j.isRemote),
      url: j.jobUrl,
      postedAt: j.publishedAt ?? null,
    }));
}
Enter fullscreen mode Exit fullscreen mode

a couple of things I noticed while testing. Greenhouse has no clean remote flag in the list endpoint, so I regex the location name, which is good enough for "Remote - Germany" style labels but not perfect. Lever and Ashby both tell you directly. and Ashby returns isListed, so I drop unlisted ones

I typed the raw responses as any on purpose. for a side project I'd rather have the mapping in one obvious place than maintain three big response types. if this grows I'd put zod on the raw payloads

fetch everything, don't let one board kill the run

const fetchers = { greenhouse: fromGreenhouse, lever: fromLever, ashby: fromAshby };

async function syncAll(boards: Board[]) {
  const results = await Promise.allSettled(
    boards.map((b) => fetchers[b.ats](b.slug))
  );
  const jobs: Job[] = [];
  results.forEach((r, i) => {
    if (r.status === "fulfilled") jobs.push(...r.value);
    else console.warn(`skip ${boards[i].ats}/${boards[i].slug}: ${r.reason}`);
  });
  return jobs;
}

const boards: Board[] = [
  { ats: "greenhouse", slug: "vercel" },
  { ats: "lever", slug: "leverdemo" },
  { ats: "ashby", slug: "ashby" },
  { ats: "greenhouse", slug: "this-board-does-not-exist" },
];

const jobs = await syncAll(boards);
const remote = jobs.filter((j) => j.remote);
console.log(`${jobs.length} jobs, ${remote.length} remote`);
Enter fullscreen mode Exit fullscreen mode

Promise.allSettled is the important bit. companies switch ATS or rename their board all the time, and with Promise.all one 404 throws away every other result. I left a fake slug in the list to prove it: the run logs skip greenhouse/this-board-does-not-exist: Error: 404 ... and keeps going with the rest

when I ran it today it came back with 170 jobs across the three real boards. that number will be different when you run it, it's live data

with a few hundred companies you'd want a small concurrency limit instead of firing everything at once, just to be polite to the APIs. a simple chunk loop of 10 at a time is enough

what's new since last run

a job board is only useful if it shows fresh jobs, so the next step is remembering what you've already seen. for a first version a JSON file is fine:

import { readFile, writeFile } from "node:fs/promises";

type Seen = Record<string, string>; // job id -> first time we saw it

async function newJobsSince<T extends { id: string }>(jobs: T[], file = "seen.json") {
  const seen: Seen = JSON.parse(await readFile(file, "utf8").catch(() => "{}"));
  const now = new Date().toISOString();
  const fresh = jobs.filter((j) => !seen[j.id]);
  for (const j of fresh) seen[j.id] = now;
  await writeFile(file, JSON.stringify(seen, null, 2));
  return fresh;
}
Enter fullscreen mode Exit fullscreen mode

first run, everything is new. second run, only what got posted in between. that's your "new today" email or feed. in production I'd swap the file for a table with a unique index on id and use insert ... on conflict do nothing, but the logic is the same

finding the board from a careers link

the last annoying step is figuring out which ATS a company uses. most careers pages link out to the hosted board, so you can detect it from the URL:

function detectBoard(url: string) {
  const u = new URL(url);
  const slug = u.pathname.split("/").filter(Boolean)[0];
  if (u.hostname.endsWith("greenhouse.io")) return { ats: "greenhouse", slug };
  if (u.hostname === "jobs.lever.co") return { ats: "lever", slug };
  if (u.hostname === "jobs.ashbyhq.com") return { ats: "ashby", slug };
  return null;
}
Enter fullscreen mode Exit fullscreen mode

feed it https://job-boards.greenhouse.io/vercel/jobs/6231864004 and you get { ats: "greenhouse", slug: "vercel" }. a null means the company hosts its own page or uses another ATS, and that's where real scraping starts. I'd leave those for later

what I'd do next

  • run syncAll on a cron (a Cloudflare Worker cron trigger or a GitHub Action is plenty)
  • keep the description out of the list call and fetch it only for jobs someone opens. Greenhouse has ?content=true if you want it inline
  • add filters people actually pay for: remote only, country, posted in the last 24h

the code here is maybe 100 lines and it covers three of the big ATSs. what made Career Hound work, per Roman, was the content and the paywall, not the tech. but it's nice to know the tech side of a "simple website" can really be this small

sources

I post indie app cases and build logs like this on X and Instagram

Top comments (0)