Skip to content

Install and use job data

Terminal window
npm i @chrisdothtml/job-scraper

Use the equivalent bun add, pnpm add, yarn add, or deno add npm: command. The package has no runtime dependencies and supports Node 22+, Bun, Deno and browsers through a bundler. Browser board access depends on CORS support.

Fetch with fetchJob, then use renderScrapedPosting to get readable Markdown. This self-contained example uses synthetic source data so it runs without a network request:

// A self-contained example: replace this synthetic job with await fetchJob(url).
import { renderScrapedPosting, type Job } from '@chrisdothtml/job-scraper';

const job: Job = {
  id: 'example-1',
  title: 'Software Engineer',
  location: 'Remote',
  url: 'https://example.com/apply/example-1',
  content: JSON.stringify({
    company_name: 'Example Company',
    description: '<h2>About the role</h2><p>Build useful software.</p>',
    benefits: ['Flexible hours'],
    application_questions: [],
  }),
};

const complete = renderScrapedPosting(job); // mode: 'complete' by default
const compact = renderScrapedPosting(job, {
  mode: 'compact',
  postingUrl: 'https://example.com/jobs/example-1',
});

console.log(complete.markdown); // readable posting plus all parsed source fields
console.log(compact.body); // selected, deduplicated posting prose
console.log(complete.raw.content === job.content); // true: exact source retained

Complete mode is the default: all parsed JSON fields and outer job metadata are represented, including empty containers and extra application/source data. Compact mode selects and deduplicates posting prose for shorter prompts; it may omit meaningful fields. Neither representation is a lossless serialization. The original job object and exact content string remain in raw and raw.content; keep them alongside the Markdown for provenance. HTML conversion is not a sanitizer. Treat scraped content as untrusted when rendering it in a frontend or building model prompts.

Set postingUrl if the source’s job.url points to an application form and you know the public posting URL. It changes the displayed link without altering the source object.

When a downstream consumer expects the original board payload, retain job.content instead. It is usually serialized JSON, but can also be Markdown or plaintext. This checked helper preserves that source string:

// Turn whatever job URL a user pastes into structured data for an LLM, a
// resume tailor, an application tracker, ...
import { fetchJob } from '@chrisdothtml/job-scraper';

export async function jobFromUrl(url: string) {
  // a board link, or the company's own careers page with a board embedded
  const job = await fetchJob(url);
  return {
    title: job.title,
    location: job.location,
    url: job.url,
    // whatever the board serves: usually JSON, sometimes markdown or plain
    // text. Models read any of them fine
    description: job.content,
  };
}

const job = await jobFromUrl(
  'https://careers.airbnb.com/positions/8184174?gh_jid=8184174'
);
const prompt = `Tailor my resume to this posting:\n\n${JSON.stringify(job)}`;

A careers page must carry the requested id (for example gh_jid) or you must pass an explicit id. A board landing page is not a request for its first linked job.

Use listCompanyJobs through your bundler. Distinguish blocked requests so you can hand them to your server:

// In a frontend app, through any bundler. Same API as Node: the registry
// lives in localStorage, and board responses in the Cache API
import {
  configureCache,
  CorsError,
  listCompanyJobs,
  time,
  UnresolvedCompanyError,
  type ListedJob,
} from '@chrisdothtml/job-scraper';

configureCache({ ttl: time.hour });

export async function loadJobs(
  company: string
): Promise<ListedJob[] | 'blocked'> {
  try {
    return await listCompanyJobs(company);
  } catch (error) {
    // only some boards allow cross-origin requests. The rest work from Node,
    // so hand those off to your server
    if (error instanceof CorsError) return 'blocked';
    if (error instanceof UnresolvedCompanyError && error.blocked) {
      return 'blocked';
    }
    throw error;
  }
}

See browser behavior before choosing boards.

Check whether a saved posting is still open

Section titled “Check whether a saved posting is still open”

A missing posting is different from a failed request. This helper treats JobNotFoundError and HTTP 404/410 as closed, while propagating rate limits and network failures:

// Check whether saved jobs are still posted, e.g. to prune a tracker
import {
  configureCache,
  fetchJob,
  HttpError,
  JobNotFoundError,
  time,
} from '@chrisdothtml/job-scraper';

// a posting answered from a day-old cache could have closed since
configureCache({ ttl: time.hour });

export async function isStillOpen(url: string): Promise<boolean> {
  try {
    await fetchJob(url);
    return true;
  } catch (error) {
    // the board's list no longer has it
    if (error instanceof JobNotFoundError) return false;
    // or the board answers for it with a 404 (or a 410)
    if (error instanceof HttpError && [404, 410].includes(error.status)) {
      return false;
    }
    // anything else (a network error, a rate limit) isn't an answer
    throw error;
  }
}

Set an appropriate cache TTL so an old successful response does not hide a recent closure.

Run this Node script with your own scheduler (cron or CI). The first run prints all matching jobs, then it remembers ids per company. Failed companies retain their prior seen ids. It disables board-response caching to compare fresh lists:

// Run on a schedule (cron, a CI job, ...) to print postings that appeared
// since the last run. The first run prints everything
import { readFile, writeFile } from 'node:fs/promises';
import { configureCache, listCompanyJobs } from '@chrisdothtml/job-scraper';

const watchlist = ['Airbnb', 'Klaviyo', 'https://jobs.ashbyhq.com/zapier'];
const seenFile = './seen-jobs.json';

// board responses are cached for about a day by default; a daily diff wants
// today's list
configureCache({ enabled: false });

const seen: Record<string, string[]> = JSON.parse(
  await readFile(seenFile, 'utf8').catch(() => '{}')
);

const results = await Promise.allSettled(
  watchlist.map((company) => listCompanyJobs(company))
);

results.forEach((result, i) => {
  const company = watchlist[i]!;
  if (result.status === 'rejected') {
    console.error(`${company}: ${result.reason}`);
    return;
  }

  const known = new Set(seen[company]);
  for (const job of result.value) {
    if (known.has(job.id)) continue;
    if (!job.title.toLowerCase().includes('engineer')) continue;
    console.log(`${company}: ${job.title} (${job.location}) ${job.url}`);
  }
  seen[company] = result.value.map((job) => job.id);
});

await writeFile(seenFile, JSON.stringify(seen, null, 2));

The package supplies scraping APIs; scheduling and alert delivery belong to your application. For fetching many full postings, reuse one scraper instance to share its in-memory listing cache.