# Job Postings Scraper — Greenhouse, Lever, Ashby, Workable (`crearcode/ats-jobs-normalizer`) Actor

Scrape jobs from company career pages across 6 ATS platforms — Greenhouse, Lever, Ashby, Workable, SmartRecruiters, Recruitee — into one schema. Salary ranges parsed and annualized, remote/hybrid/onsite detection, LATAM eligibility flag, delta feed. No personal data.

- **URL**: https://apify.com/crearcode/ats-jobs-normalizer.md
- **Developed by:** [Juan Carlos Gutiérrez Huérfano](https://apify.com/crearcode) (community)
- **Categories:** Jobs, Developer tools, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

Pay per usage

This Actor is paid per platform usage. The Actor is free to use, and you only pay for the Apify platform usage, which gets cheaper the higher subscription plan you have.

Learn more: https://docs.apify.com/platform/actors/running/actors-in-store#pay-per-usage

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Job Postings Scraper — Greenhouse, Lever, Ashby, Workable, SmartRecruiters, Recruitee

Pulls open roles straight from companies' own applicant tracking systems and returns
**one clean schema** across all six, with salary bands parsed into comparable annual
figures and an eligibility flag for Latin America.

No personal data is collected or emitted — job postings only.

### Supported sources

| ATS | Board URL | Board entry |
|---|---|---|
| Greenhouse | `boards.greenhouse.io/stripe` | `greenhouse:stripe` |
| Lever | `jobs.lever.co/leverdemo` | `lever:leverdemo` |
| Ashby | `jobs.ashbyhq.com/ramp` | `ashby:ramp` |
| Workable | `apply.workable.com/hospitable` | `workable:hospitable` |
| SmartRecruiters | `jobs.smartrecruiters.com/Acme` | `smartrecruiters:Acme` |
| Recruitee | `tether.recruitee.com` | `recruitee:tether` |

### Why this exists

Every ATS already publishes its job board as a public JSON endpoint. The hard part was
never fetching it; it's that each one names things differently, buries compensation in
free prose, and describes location in a dozen incompatible ways. Existing scrapers hand
you the raw payload and leave the normalization to you.

This one does the normalization:

- **Salary parsed and annualized.** Structured fields when the ATS provides them,
  otherwise parsed from the description — English, Spanish and Portuguese, multi-currency,
  with `annualizedMin`/`annualizedMax` so an hourly US rate and a monthly Colombian
  salary sort in the same column. Every band carries the exact `rawText` it came from,
  and a `confidence` of `structured` or `parsed`, so you can audit or discard.
- **Work mode classified** as `remote` / `hybrid` / `onsite` / `unknown` — reading the
  title and body, not just the location string. "Hybrid remote" resolves to hybrid, and
  a named city with no remote signal resolves to onsite; a bare country stays `unknown`
  rather than being guessed at.
- **LATAM eligibility flag.** `latamFriendly` is true when the role sits in a Latin
  American country *or* is remote with a scope that includes the region — "Remote - LATAM",
  "Americas", "must overlap GMT-5", "work from anywhere". US-built job scrapers treat
  those strings as noise; if you hire in the region, that flag is the product.

  It is deliberately conservative. A role anchored to a non-LATAM country and not marked
  remote is never flagged, no matter what the description says — a US-based role whose
  body mentions "our business in the Americas" is a business-scope mention, not a hiring
  signal, and treating it as one would make the flag worthless.
- **Language detection** (`es` / `pt` / `en`) on the posting body.
- **Delta feed.** Pass `postedAfter` with your previous run's timestamp and get only
  what's new, instead of re-pulling every board in full.

### Input

```json
{
  "boards": [
    "greenhouse:stripe",
    "lever:leverdemo",
    "ashby:ramp",
    "workable:hospitable",
    "smartrecruiters:smartrecruiters",
    "recruitee:tether"
  ],
  "postedAfter": "2026-07-01T00:00:00Z",
  "latamOnly": true,
  "withSalaryOnly": false,
  "workMode": ["remote"],
  "titleIncludes": ["backend", "platform"],
  "maxItems": 0,
  "descriptionMaxChars": 5000
}
```

The slug is whatever appears in the board's public URL — see the table above. Only
`boards` is required; every filter is optional.

### Output

One record per posting:

```json
{
  "id": "greenhouse:stripe:7967890",
  "source": "greenhouse",
  "companySlug": "stripe",
  "companyName": "Stripe",
  "title": "Consumer Operations Associate",
  "url": "https://stripe.com/jobs/search?gh_jid=7967890",
  "locationRaw": "Mexico City",
  "countryCode": "MX",
  "latamFriendly": true,
  "workMode": "onsite",
  "department": "Operations",
  "employmentType": "Full-time",
  "postedAt": "2026-07-22T15:16:54-04:00",
  "updatedAt": "2026-07-27T11:17:30-04:00",
  "salary": {
    "min": 120000,
    "max": 160000,
    "currency": "USD",
    "period": "year",
    "annualizedMin": 120000,
    "annualizedMax": 160000,
    "rawText": "$120,000 - $160,000",
    "confidence": "parsed"
  },
  "language": "en",
  "description": "...",
  "scrapedAt": "2026-07-31T18:00:00Z"
}
```

`id` is stable across runs, so you can upsert on it.

A `RUN_SUMMARY` record is written to the key-value store with per-board failures,
postings scanned vs emitted, and the filters applied — so a run that returns fewer
rows than you expected tells you why.

### Reliability

These are documented public JSON endpoints with no authentication and no anti-bot
layer, so there is nothing here that silently degrades when a site ships a redesign.
Requests retry three times with exponential backoff; a `404` is treated as a permanent
"no such board" rather than retried. Boards are fetched five at a time — enough to be
fast, not enough to be rude. One failing board does not sink the run; all boards failing
does, loudly.

A typical run across six boards reads well over a thousand postings in about ten seconds.

### What this does not do

- **No personal data.** No recruiter or hiring-manager names, no contact details, no
  applicant information. This is a deliberate design constraint, not an oversight.
- **No authenticated scraping** and no credential sharing.
- **No aggregator sites.** It reads each company's own board directly, which is why the
  data is first-hand and the uptime is the ATS's rather than a middleman's.
- **`companyName` is null on Lever.** Lever's public postings API does not expose the
  company's display name, and inventing one from the slug would be a guess. The other
  five sources return it when the ATS publishes it.

### Local development

```bash
npm install
npm run build
npm run test:local    # offline assertions + live endpoint probe
```

`test:local` checks the salary parser and the location, work-mode and LATAM classifiers
against fixtures without needing network — including a regression test for the
"business in the Americas" false positive — then probes the six live endpoints and
reports how many postings each returned.

### Issues

Found a bad parse, or want a field in a different shape? Open an issue on the Actor.
Include the board entry and the posting URL and it is usually a quick fix.

# Actor input Schema

## `boards` (type: `array`):

One entry per board, as "source:slug", where slug is the identifier in the board's public URL. Supported sources: greenhouse, lever, ashby, workable, smartrecruiters, recruitee. Examples: boards.greenhouse.io/stripe → "greenhouse:stripe"; jobs.ashbyhq.com/ramp → "ashby:ramp"; apply.workable.com/acme → "workable:acme"; jobs.smartrecruiters.com/Acme → "smartrecruiters:Acme"; acme.recruitee.com → "recruitee:acme".

## `postedAfter` (type: `string`):

ISO date. Only returns postings published or updated at or after this moment. Use the previous run's timestamp to poll cheaply instead of re-pulling every board in full.

## `latamOnly` (type: `boolean`):

Keep only roles a candidate based in Latin America could take: located in a LATAM country, or remote with a scope that includes the region (LATAM, Americas, GMT-3..-6, worldwide).

## `withSalaryOnly` (type: `boolean`):

Keep only postings where a salary band was resolved, either from the ATS's structured fields or parsed from the description.

## `workMode` (type: `array`):

Keep only these work modes.

## `titleIncludes` (type: `array`):

Case-insensitive substring match against the job title, e.g. "backend", "java", "platform".

## `maxItems` (type: `integer`):

Hard cap on returned postings. 0 means no limit.

## `descriptionMaxChars` (type: `integer`):

Truncate the plain-text description to this many characters. Lower it to cut dataset size when you only need metadata.

## Actor input object example

```json
{
  "boards": [
    "greenhouse:stripe",
    "ashby:ramp",
    "workable:acme"
  ],
  "latamOnly": false,
  "withSalaryOnly": false,
  "maxItems": 0,
  "descriptionMaxChars": 5000
}
```

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "boards": [
        "greenhouse:stripe",
        "ashby:ramp"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("crearcode/ats-jobs-normalizer").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "boards": [
        "greenhouse:stripe",
        "ashby:ramp",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("crearcode/ats-jobs-normalizer").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print("💾 Check your data here: https://console.apify.com/storage/datasets/" + run["defaultDatasetId"])
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "boards": [
    "greenhouse:stripe",
    "ashby:ramp"
  ]
}' |
apify call crearcode/ats-jobs-normalizer --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "command": "npx",
            "args": [
                "mcp-remote",
                "https://mcp.apify.com/?tools=crearcode/ats-jobs-normalizer",
                "--header",
                "Authorization: Bearer <YOUR_API_TOKEN>"
            ]
        }
    }
}

```

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/acts/XQRy6Q8wfZS2YrjaD/builds/CvCagJSFWwQjth08w/openapi.json
