Skip to content

About

Levels.fyi compensation scraper for Node.js: request tech pay by company slug and job family and receive base, equity, bonus and total comp per level as JSON for your app or API. JavaScript example for the Devil Scrapes Apify Actor.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Levels.fyi Compensation Scraper — JavaScript example

Levels.fyi Compensation Scraper for JavaScript and Node.js: salary, stock and bonus per level as JSON

Run on Apify JavaScript MIT license CI

Use this repository to call the Levels.fyi Compensation Scraper from Node.js. It is one ES module on top of the official apify-client package. Your code supplies Levels.fyi company slugs and job families, Apify runs the Actor in the cloud, and you get back plain objects describing reported tech pay: level, location, experience, base salary, yearly equity, yearly bonus and the total. That is enough to power an offer-comparison page, a salary endpoint in your own API or a nightly sync into Postgres, without your servers ever loading a Levels.fyi page.

Actor: Levels.fyi Compensation Scraper · Publisher: Devil Scrapes · Also in Python: levels-fyi-compensation-scraper-python

🔥 What you get

  • Pay components stay apart: in the default mode each row is a single self-reported offer, so salary, annual equity and annual bonus arrive as three numbers next to the total rather than one blended figure.
  • Two page types, one row shape: a company's salaries page and its per-job-family pages both map onto the same 21 columns, and anything a page type does not publish is left null instead of estimated.
  • Every company and job family combined: list three companies and two job families and the Actor visits all six pages in turn.
  • A run-wide row budget: maxResults counts rows across all pages, and the Actor stops opening new pages once the budget is spent.
  • Sample size travels with the data: each row repeats its level bucket's submission counts, so you can see how many reports sit behind the handful of rows you received.
  • Bad slugs do not sink a batch: a company or job family that yields nothing is logged and skipped, and the run only fails when every page comes back empty.
  • Transient errors are retried with growing pauses before a page is given up on.

🎯 Use cases

Back an offer-comparison feature

Expose a route that takes a company and level, looks up cached rows and returns the median package next to the user's offer. Because salary, equity and bonus are separate keys, the frontend can chart the gap per component instead of a single delta.

Refresh a compensation table from a serverless job

Trigger node index.js from a scheduled Lambda, Cloud Run job or GitHub Actions workflow and upsert the rows keyed on company, job family, level and offer date. Recruiters then query fresh numbers from your own database.

Push new reports to a webhook

After each run, compare the latest offer_date per level with the one you stored last time and post anything newer to a Slack or Teams webhook. A recruiting team hears about fresh data points without opening a browser.

Enrich job postings with market pay

If your app already lists openings, join each posting's company and role to these rows and display a reported pay range beside it. The company_slug field is the stable join key.

⚡ Quick start

You need an Apify account and an API token from Console → Settings → Integrations. Apify's free plan includes monthly usage credit; this Actor's event prices are listed under Pricing below.

git clone https://github.com/devilscrapes/levels-fyi-compensation-scraper-javascript.git
cd levels-fyi-compensation-scraper-javascript
npm install
export APIFY_TOKEN=your_token_here
node index.js                       # uses input.json
node index.js my-input.json --out data --max-charge 2

The script starts the Levels.fyi Compensation Scraper, waits for it to finish, prints a link to the run in Apify Console and saves every result to results.json and results.csv. It exits with code 1 and the run's status message if the run fails.

🧩 The core call

Here is the whole integration in five statements. call() resolves when the run has finished, and listItems() returns the rows written to the run's default dataset.

import { ApifyClient } from 'apify-client';

const client = new ApifyClient({ token: 'YOUR_APIFY_TOKEN' });
const run = await client.actor('DevilScrapes/levels-fyi-compensation-scraper').call({"mode": "company_job_family", "companies": ["google"], "jobFamilies": ["software-engineer"], "maxResults": 5, "proxyConfiguration": {"useApifyProxy": true}});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
console.log(items);

📥 Input

index.js passes input.json through without changes. This example points at Google's software engineer salaries and stops at 5 rows, so a trial run costs very little. Each entry in companies and jobFamilies mirrors a Levels.fyi URL: /companies/google/salaries/software-engineer means company google, family software-engineer. Set mode to company_overview and leave out jobFamilies when you want one aggregate row per title.

{
  "mode": "company_job_family",
  "companies": [
    "google"
  ],
  "jobFamilies": [
    "software-engineer"
  ],
  "maxResults": 5,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}
Field Type Required Default Allowed values
mode string no "company_job_family" company_job_family, company_overview
companies array yes ["google"] —
jobFamilies array no [] —
maxResults integer no 200 —
proxyConfiguration object no {"useApifyProxy": true} —

📤 Output

Every object has the same keys whichever mode produced it; check granularity before you rely on a column. Overview rows hold a median total and leave the per-component fields as null, while submission rows fill them in. Numbers arrive as JSON numbers, and dates as ISO 8601 strings.

Field Type Always present
granularity string yes
company string yes
company_slug string yes
job_family string or null no
job_family_slug string or null no
title string or null no
level string or null no
track string or null no
location string or null no
years_of_experience number or null no
years_at_company number or null no
base_salary number or null no
stock_annual_value number or null no
bonus_annual_value number or null no
total_compensation number or null no
currency_code string yes
offer_date string or null no
level_submission_count integer or null no
level_submission_count_last_12_months integer or null no
source_url string yes
scraped_at string yes

💰 Pricing

Event Price (USD)
Actor start $0.2
Result row $0.0018

Prices as configured on 2026-08-20. The Actor page always shows the current price. Runs are billed to the Apify account that owns the token. Use --max-charge to cap what a single run can cost.

🛠️ Working with the data in JavaScript

This script collapses individual reports into a per-level ladder and saves it as pay-ladder.json, a file a frontend or API handler can serve directly. It needs nothing beyond Node.js built-ins.

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

const rows = JSON.parse(await readFile('results.json', 'utf8'));
const median = (values) => {
    const sorted = values.filter((v) => typeof v === 'number').sort((a, b) => a - b);
    if (sorted.length === 0) return null;
    const mid = Math.floor(sorted.length / 2);
    return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
};

const buckets = new Map();
for (const row of rows) {
    if (row.granularity !== 'individual_submission') continue;
    const key = [row.company_slug, row.job_family_slug, row.level ?? 'unlevelled'].join('|');
    if (!buckets.has(key)) buckets.set(key, []);
    buckets.get(key).push(row);
}

const ladder = [...buckets.values()].map((group) => ({
    company: group[0].company,
    jobFamily: group[0].job_family,
    level: group[0].level,
    currency: group[0].currency_code,
    sampleRows: group.length,
    bucketSubmissions: group[0].level_submission_count,
    medianBase: median(group.map((r) => r.base_salary)),
    medianStock: median(group.map((r) => r.stock_annual_value)),
    medianTotal: median(group.map((r) => r.total_compensation)),
}));
ladder.sort((a, b) => (a.medianTotal ?? 0) - (b.medianTotal ?? 0));
await writeFile('pay-ladder.json', JSON.stringify(ladder, null, 2));
console.table(ladder);

❓ FAQ

Is there an official Levels.fyi API I could use instead?

If you hold a data licence from Levels.fyi, use it. Otherwise this Actor turns the public company pages into structured JSON, and your service needs nothing but an Apify token.

Will TypeScript understand the response?

apify-client ships its own type declarations, so the run and dataset calls are typed. Declare an interface for the row keys listed in the Output table and cast items to it.

What does a typo in a slug do to my job?

The page for that pair returns nothing, the Actor logs it and moves to the next pair, and the run still succeeds if any pair produced data. If every pair is empty, the run fails, so your scheduler can raise an alert.

How do I keep costs predictable in production?

Set maxResults to the number of rows you actually need and pass --max-charge when testing. The row limit covers the whole run, not each company, so it doubles as a budget.

Are these figures verified salaries?

They are what Levels.fyi publishes from user submissions at the time of the run. The Actor copies them faithfully and adds nothing, so treat each row as one reported data point.

🧯 Troubleshooting

  • Set APIFY_TOKEN first: export the token in the same shell, or put it in a local .env file your shell loads. Never commit it.
  • Run finished with FAILED or ABORTED: open the printed Console link; the run log says why. Input validation errors name the field.
  • Fewer rows than expected: a --max-charge cap also limits how many results can be charged. Raise the cap or lower the limits in your input.
  • Something looks wrong in the data: open an issue on the Actor page so the maintainers see it.

🔗 More from Devil Scrapes

Related Actors:


MIT licensed example code. The Actor itself runs on Apify and is maintained by Devil Scrapes. Not affiliated with Levels.fyi.

About

Levels.fyi compensation scraper for Node.js: request tech pay by company slug and job family and receive base, equity, bonus and total comp per level as JSON for your app or API. JavaScript example for the Devil Scrapes Apify Actor.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages