I wrote this article with AI assistance. I ran every snippet on 12 Nov 2026 with Python 3.13 and Node 24 (and checked the scripts on Python 3.9 and Node 22). All output below is copied from those runs. Job boards change every day, so your numbers will differ.

"Is this company mostly hiring engineers or sales?" "How many of their roles are remote?" "Do they post salaries?" You can answer these questions for many companies at once from their public job boards. You can also get them badly wrong, because every company labels its jobs differently.

This tutorial builds a small comparison table for ten companies. Then it checks the remote, pay and team columns, because each one was misleading on my first run.

Who this is for: developers and analysts who want to compare hiring across a list of companies, for example for market research, a job board, or sales prospecting.

What you need: Python 3.9 or later and Node 18 or later. The scripts use only the standard library and built-in fetch, so there's nothing to install.

Terms used in this post

  • ATS (applicant tracking system): the software a company uses to post jobs and collect applications. Greenhouse, Lever and Ashby each have a public, read-only job board API that needs no key.
  • Snapshot: every open job on a company's board at one moment.
  • Remote share: the percentage of open jobs that the company marks as remote.
  • Pay share: the percentage of open jobs that include a salary range in a structured field (a field made for pay, not text inside the description).
  • Team mix: how open jobs split across functions such as engineering, sales and support.

My first article covers these three APIs and their salary fields in detail. This one keeps only the fields it needs to compare companies.

1. Take a snapshot of ten job boards (Python)

snapshot.py reads ten boards, reduces each job to six fields, saves the result to snapshot.json, and prints one row per company. Greenhouse needs two requests per board, because its jobs list doesn't include departments unless you add content=true, which also downloads every job description. Lever and Ashby need one.

# snapshot.py: compare open jobs across companies. Python 3.9+, standard library only. # Run: python3 snapshot.py (writes snapshot.json and prints one row per company) import json, re, time, urllib.error, urllib.request from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timedelta, timezone BOARDS = [("greenhouse", "airbnb"), ("greenhouse", "cloudflare"), ("greenhouse", "discord"), ("greenhouse", "figma"), ("greenhouse", "gitlab"), ("lever", "spotify"), ("lever", "outreach"), ("ashby", "linear"), ("ashby", "notion"), ("ashby", "supabase")] def get_json(url, tries=3): for attempt in range(1, tries + 1): req = urllib.request.Request(url, headers={"User-Agent": "hiring-compare/1.0 (you@example.com)"}) try: with urllib.request.urlopen(req, timeout=60) as r: return json.load(r) except urllib.error.HTTPError as e: if (e.code == 429 or e.code >= 500) and attempt < tries: time.sleep(2 ** attempt) continue raise # Words that mean "remote" in free-text locations. Cloudflare, for example, says "Distributed". REMOTE = re.compile(r"\b(remote|distributed|anywhere|work from home)\b", re.I) def is_remote(flag, location): # Trust a remote flag when the ATS has one, but also read the location text: # some boards say "Remote, Global" in the location and leave the flag off. return bool(flag) or bool(REMOTE.search(location or "")) def epoch_ms(ms): return datetime.fromtimestamp(ms / 1000, timezone.utc).isoformat() def greenhouse(slug): base = f"https://boards-api.greenhouse.io/v1/boards/{slug}" jobs = get_json(f"{base}/jobs?pay_transparency=true")["jobs"] # The jobs list has no department, so read the departments endpoint as well. dept = {j["id"]: d["name"] for d in get_json(f"{base}/departments")["departments"] for j in d["jobs"]} return [{"title": j["title"], "department": dept.get(j["id"]), "location": (j.get("location") or {}).get("name"), "remote": is_remote(False, (j.get("location") or {}).get("name")), # no flag on Greenhouse "posted": j.get("first_published") or j.get("updated_at"), "has_pay": bool(j.get("pay_input_ranges"))} for j in jobs] def lever(slug): return [{"title": j["text"], "department": j["categories"].get("department"), "location": j["categories"].get("location"), "remote": is_remote(j.get("workplaceType") == "remote", j["categories"].get("location")), "posted": epoch_ms(j["createdAt"]), "has_pay": bool(j.get("salaryRange"))} for j in get_json(f"https://api.lever.co/v0/postings/{slug}?mode=json")] def ashby(slug): data = get_json(f"https://api.ashbyhq.com/posting-api/job-board/{slug}?includeCompensation=true") return [{"title": j["title"], "department": j.get("department"), "location": j.get("location"), # isRemote and workplaceType can disagree; prefer workplaceType when it is set. "remote": is_remote(j["workplaceType"] == "Remote" if j.get("workplaceType") else j.get("isRemote"), j.get("location")), "posted": j.get("publishedAt"), "has_pay": any(c.get("compensationType") == "Salary" for c in (j.get("compensation") or {}).get("summaryComponents") or [])} for j in data["jobs"] if j.get("isListed", True)] FETCH = {"greenhouse": greenhouse, "lever": lever, "ashby": ashby} def snapshot(board): platform, slug = board return [dict(job, company=slug, platform=platform) for job in FETCH[platform](slug)] def pct(part, whole): return f"{100 * part / whole:.0f}%" if whole else "-" if __name__ == "__main__": with ThreadPoolExecutor(max_workers=3) as pool: # a few boards at a time per_company = list(pool.map(snapshot, BOARDS)) jobs = [j for rows in per_company for j in rows] with open("snapshot.json", "w") as f: json.dump(jobs, f) cutoff = datetime.now(timezone.utc) - timedelta(days=30) def recent(j): return j["posted"] and datetime.fromisoformat(j["posted"].replace("Z", "+00:00")) >= cutoff print(f"{'company':<11}{'open':>6}{'remote':>8}{'pay':>6}{'<30d':>6} top department") for (platform, slug), rows in zip(BOARDS, per_company): depts = {} for j in rows: depts[j["department"] or "(none)"] = depts.get(j["department"] or "(none)", 0) + 1 top, n = max(depts.items(), key=lambda kv: kv[1]) if depts else ("-", 0) print(f"{slug:<11}{len(rows):>6}{pct(sum(j['remote'] for j in rows), len(rows)):>8}" f"{pct(sum(j['has_pay'] for j in rows), len(rows)):>6}" f"{pct(sum(1 for j in rows if recent(j)), len(rows)):>6} {top} ({n})") print(f"{len(jobs)} jobs saved to snapshot.json") 

Run it with python3 snapshot.py:

company open remote pay <30d top department airbnb 207 10% 81% 50% Software Engineering (51) cloudflare 482 13% 0% 31% Field Sales (65) discord 26 35% 96% 31% Data Science & Engineering (3) figma 142 0% 65% 35% Sales (44) gitlab 259 88% 43% 48% EMEA - Enterprise (18) spotify 85 32% 0% 35% Engineering (29) outreach 16 69% 62% 25% Engineering (5) linear 33 91% 39% 33% GTM (18) notion 157 1% 0% 34% Sales (59) supabase 76 99% 1% 45% Engineering (31) 1483 jobs saved to snapshot.json 

The columns are: open jobs, remote share, pay share, the share of jobs first posted in the last 30 days, and the department with the most openings.

The table already suggests some differences. Linear and Supabase look almost fully remote, while Figma lists no remote roles. Airbnb and Discord attach structured pay to most postings. About half of Airbnb's and GitLab's open roles were posted in the last 30 days.

Before you quote any of these numbers, check how each column is built. The next three sections check the remote, pay and team columns.

2. Check what "remote" means on each board

My first version of snapshot.py was simpler. On Greenhouse, it counted a job as remote when the location contained the word "remote". On Lever and Ashby, it trusted the remote flag alone. That version printed these rows:

cloudflare 482 0% 0% 31% Field Sales (65) notion 157 59% 0% 34% Sales (59) supabase 76 64% 1% 45% Engineering (31) 

All three numbers were misleading, each for a different reason.

Cloudflare doesn't use the word "remote". Greenhouse has no remote field, so the location text is all you get. When I counted Cloudflare's location labels, 59 jobs said Distributed and 6 said Distributed; Hybrid. Adding distributed to the pattern moved Cloudflare from 0% to 13%.

Supabase puts "Remote" in the location and leaves the flag empty on some jobs. To find these, print the most common location labels among the jobs you did not count as remote. labels.py does that with the saved snapshot:

# labels.py: show the location labels that were NOT counted as remote. # Python 3.9+. Run after snapshot.py: python3 labels.py import json from collections import Counter jobs = json.load(open("snapshot.json")) for company in dict.fromkeys(j["company"] for j in jobs): # keeps the original order labels = Counter(j["location"] for j in jobs if j["company"] == company and not j["remote"]) print(f"{company:<11}", " | ".join(f"{label} ({n})" for label, n in labels.most_common(3)) or "(all remote)") 

Before the fix, Supabase's row was Remote, Global (17) | Remote, EMEA (4) | Remote, APAC (4). Reading the location text as well as the flag moved Supabase from 64% to 99%. After the fixes, the output looks like this:

airbnb United States (41) | Canada (16) | United States (16) cloudflare Hybrid (363) | In-Office (44) | Hybrid; In-Office (7) discord San Francisco Bay Area (15) | San Francisco Bay Area (1) | San Francisco Bay Area or Los Angeles Area (1) figma San Francisco, CA • New York, NY • United States (67) | London, England (20) | San Francisco, CA • New York, NY (14) gitlab Bangalore, India (30) spotify London (22) | New York, NY (12) | Stockholm (9) outreach Seattle, WA (4) | London (1) linear Singapore (2) | London (1) notion San Francisco, California (64) | Dublin, Ireland (32) | Tokyo, Japan (21) supabase San Francisco, CA, US (1) 

The leftovers now look like real offices, plus one hard case: a location of just United States (Airbnb, 41 jobs). That label might mean remote within the country, or it might not. I suggest you leave such jobs uncounted and say so in your results, rather than guess.

Notion's two remote fields disagree. Ashby returns both isRemote (true or false) and workplaceType (Remote, Hybrid or OnSite), and either one can be missing. flags.py counts how three boards combine them:

# flags.py: count how Ashby boards combine their two remote fields. Python 3.9+. # Run: python3 flags.py import json, urllib.request from collections import Counter for slug in ["linear", "notion", "supabase"]: url = f"https://api.ashbyhq.com/posting-api/job-board/{slug}" req = urllib.request.Request(url, headers={"User-Agent": "hiring-compare/1.0 (you@example.com)"}) with urllib.request.urlopen(req, timeout=60) as r: jobs = [j for j in json.load(r)["jobs"] if j.get("isListed", True)] combos = Counter((j.get("isRemote"), j.get("workplaceType")) for j in jobs) print(slug, dict(combos.most_common())) 
linear {(True, 'Remote'): 30, (True, 'Hybrid'): 3} notion {(True, 'Hybrid'): 92, (None, None): 64, (True, 'Remote'): 1} supabase {(True, 'Remote'): 49, (None, None): 27} 

92 of Notion's 157 jobs have isRemote: true and workplaceType: Hybrid, mostly in offices such as San Francisco, New York and Tokyo. Another 64 have neither field set. If you trust isRemote, Notion is 59% remote. If you trust workplaceType, it's 1%. The final snapshot.py uses workplaceType when it's set, because it's the more specific field. That rule also moves 3 of Linear's 33 jobs from remote to hybrid (100% to 91%).

The general lesson: when a board has two fields for the same fact, count how they combine before you pick one.

3. Read "no pay" as "no pay found"

The pay column counts structured salary fields only. A 0% doesn't mean a company hides pay. It means the field you checked was empty:

  • Spotify (Lever) has 0% structured pay, but 24 of its 85 postings state a range in the text, such as $291,071 - $414,388. A simple regular expression for dollar ranges over Lever's text fields (descriptionPlain, lists and additionalPlain) finds them.
  • Notion (Ashby) has no salary in Ashby's compensation field on any of its 157 postings, so the structured share is 0%.
  • Cloudflare (Greenhouse) has no structured pay on any of its 482 postings. I didn't parse its job descriptions, so I can't say whether they mention pay.

If you compare pay transparency across companies, say which fields you checked. Parsing pay out of description text adds coverage, but it also adds false matches, such as funding amounts. Spot-check a sample by hand.

4. Compare team mix with department buckets (JavaScript)

Departments are free text. These ten companies used 151 different department names, from Engineering to EMEA - Enterprise to Figma Weave. To compare them, map each name to a small set of functions with regular expressions. teams.mjs does that, and adds a second opinion that ignores departments: the share of job titles that contain "engineer" or "developer".

// teams.mjs: map free-text department names to a few functions, then compare companies. // Node 18+, no packages. Run after snapshot.py: node teams.mjs import { readFileSync } from "node:fs"; // First match wins, so put the specific patterns before the broad ones. // "Solution Engineering" is pre-sales, so Sales comes before Engineering. const FUNCTIONS = [ ["Sales", /sales|gtm|bdr|\bsa\b|solution|outcomes|business development|partner|alliance|account|enterprise$|commercial|mid market|renewal|revenue|revops|pubsec/i], ["Support", /support|success|customer|professional services|consulting/i], ["Eng", /engineer|infrastructure|security|devops|data|analytics|platform|\bai\b|developer|technology|intelligence|cto/i], ["Product", /product|design|research/i], ["Mktg", /marketing|communications|content|community|advertising|brand/i], ["G&A", /people|talent|recruit|finance|fp&a|accounting|legal|policy|operations|strategy|admin|facilities|procurement|rewards|cfo|it\b/i], ]; const classify = (dept) => FUNCTIONS.find(([, re]) => re.test(dept ?? ""))?.[0] ?? "Other"; const jobs = JSON.parse(readFileSync("snapshot.json", "utf8")); const names = [...FUNCTIONS.map(([name]) => name), "Other"]; const table = new Map(); // company -> { function: count, engTitles: count } const unmatched = new Map(); for (const job of jobs) { const fn = classify(job.department); const row = table.get(job.company) ?? {}; row[fn] = (row[fn] ?? Source:https://dev.to/neorhapsody/compare-hiring-across-companies-from-public-job-boards-remote-share-pay-and-team-mix-python-js-1cb4

Related posts