DEV Community

Zakaria Blefkih
Zakaria Blefkih

Posted on

How to build a multi job board scraper feed in one Python call

If you have ever tried to assemble a jobs dataset by hand, you know the shape of the problem. LinkedIn, Indeed and Glassdoor each have their own search page, their own paging and their own idea of what a "location" or a "salary" field looks like. Running three scrapers and reconciling them is glue work, and the same role posted on all three lands in your table three times. A multi job board scraper that does the merge for you is the obvious answer, as long as it is honest about what it can and cannot reach.

This article walks through one such tool: the Apify actor flash_scraper/multi-jobboard-scraper. Its README states the pitch plainly: "One search. Up to twelve job boards. One deduplicated row per role — so a job posted to LinkedIn, Indeed and Glassdoor bills you once, not three times." Everything below comes from that README, the actor's dataset schema and one logged run. Where the README says a board is blocked or a column is blank, this article says so too.

What you get: the same 65 columns on every row

Per the README, every row carries "the same 65 columns on every row of every run — a field a board did not publish is null, never a missing column." Eight of them, with the titles and notes from the dataset schema:

Column Title in the Output tab What the schema says
title Title example: Software Development Engineer, ML Systems, Annapurna Labs
company Company Filled from: all boards
location Location Filled from: all boards
salary_text Salary example: $158,100–$213,800 / year
salary_min_annual Salary min (yearly) example: 158100
found_on_sites Found on boards example: glassdoor
duplicate_count Listings for this role example: 1
description Description Filled from: Indeed + Glassdoor always; LinkedIn only with detail fetching

found_on_sites and duplicate_count are the dedup evidence. Company names are normalized before matching, so Wipro and Wipro Limited collapse together, and the merge works across boards only: "five genuinely different openings one employer posted to one board stay five rows."

Hourly, weekly and monthly pay is normalized into salary_min_annual / salary_max_annual (hourly ×2080, weekly ×52, monthly ×12), and salary_text renders the raw salary columns as one readable string.

Seven saved Output views ship with it (Overview, Salary, Cross-board, Remote & location, Company info, Standard table, All columns), exports are CSV, JSON or Excel, and every run writes a RUN_SUMMARY record to its key-value store naming each board's outcome.

A real run

The log of a throwaway run from 2026-09-20, reproduced as written:

## multi-jobboard-scraper — throwaway run 2026-09-20 13:07 UTC, input: searchTerm 'software engineer', location 'New York, NY', boards linkedin+indeed+glassdoor+muse (other keys sent were not in the input schema and were ignored, so the run used the default cap of 20 per board)
Total: 59 jobs - indeed: 20, linkedin: 19, glassdoor: 20
9 of 59 delivered rows mention none of ['software engineer'] in their title or description: {'indeed': 3, 'glassdoor': 6}. LinkedIn in particular never answers 'nothing matched' - it returns loosely related postings. Set strictKeywordMatch=true to drop them before they are billed.
Pushed 59 items to Apify dataset
Done. 59 jobs delivered to the dataset.
Enter fullscreen mode Exit fullscreen mode

Read it honestly: three boards hit or nearly hit the default cap of 20, and The Muse, the fourth board in the input, returned no rows for this search and is absent from the per-board line. The log is shown as written, not tidied. The middle line is the actor's own off-topic warning, and the fix it names, strictKeywordMatch, is "opt-in, off by default" and removes rows before billing, so a filtered row is never charged.

The Python call

The README's "Smallest useful call", copied verbatim:

from apify_client import ApifyClient
client = ApifyClient("<APIFY_TOKEN>")
run = client.actor("flash_scraper/multi-jobboard-scraper").call(run_input={"searchTerm": "data analyst", "location": "New York, NY"})
rows = client.dataset(run["defaultDatasetId"]).list_items().items
Enter fullscreen mode Exit fullscreen mode

That is a complete run. Per the README, everything else has a working default: LinkedIn + Indeed + Glassdoor + The Muse, 20 job postings per board, full LinkedIn job details on, Apify datacenter proxy. The same JSON works in the Console, the REST API and n8n/Make/Zapier.

To make it a pipeline, tighten the input with the README's own example:

{
  "searchTerm": "data analyst",
  "location": "Austin, TX",
  "sites": ["linkedin", "indeed", "glassdoor"],
  "maxResults": 50,
  "hoursOld": 168,
  "requireSalary": true,
  "excludeTitleKeywords": ["senior", "intern"]
}
Enter fullscreen mode Exit fullscreen mode

hoursOld is a board-side recency filter. requireSalary and excludeTitleKeywords run inside the actor after scraping and before billing, and RUN_SUMMARY.filter_removed records how many rows each one took. maxResults accepts 1–500 per board.

For a recurring feed, the README's recipe is onlyNewJobs: the actor "remembers every job this exact search has already delivered (in a private named store in your own account) and delivers — and bills — only postings it has not seen before. Identical reruns bill zero (measured: 15 rows, then 0)." Memory is kept per search for 90 days. Add a webhookUrl and each run that delivers rows POSTs a digest to Slack, Discord or any JSON catch hook; quiet runs send nothing.

What it costs

The README's pricing table, with its own caveat that the Store page's Pricing tab is always authoritative:

Event Free plan Paid plans
Deduplicated job delivered $0.005 ($5 per 1,000) $0.0045 on Bronze down to $0.0035 on Diamond (live pricing record read 2026-08-29)
Run start $0.00005 $0.00005

Two worked costs at the free-plan rate, from the same section: an untouched form (the four default boards, up to 20 rows each) delivers at most 80 deduplicated jobs for at most $0.40, and a 500-job sweep costs $2.50. A job found on three boards bills once, rows removed by a filter bill nothing, and a failed or empty run bills only the $0.00005 start. Apify's free plan needs no card on file, and its monthly usage credit covers several default-size searches.

Honest limits

Copied from the README's own "What it does not do" section: it never logs in anywhere and never auto-applies; every row is public data. It cannot reach behind login walls. For LinkedIn it reads the pay span on the public search card when a posting carries one, plus posting text; the fuller base-pay block that LinkedIn's public job page shows on some postings (measured 2 of 6 on 2026-08-29) is not parsed yet. It is not a historical archive: each run reads the boards' current listings. And five selectable boards, Google Jobs, ZipRecruiter, Bayt, BDJobs and Naukri, are blocked at the source today.

From the Board status table and the field notes: Glassdoor "403s some runs entirely" and "tops out around ~28-30 rows per query — a board-side cap, not a bug." The Muse carries no salary data: 0 salary fields in 220 measured rows. DevITjobs UK was "discontinued upstream (2026-08-29)", so eleven of the twelve working boards deliver rows today. The seven remote-only boards join remote searches automatically but are deliberately not added to location searches. Three columns, experience_range, vacancy_count and company_reviews_count, are only ever published by Naukri, so expect them blank. And the keyless boards carry licensing terms: Remotive rows "must not be republished on another job board", and Remote OK requires a visible dofollow link back naming Remote OK.

Where to start

If you want a job board scraper that is a single API call away and tells you per board what it actually returned, the actor page carries the live pricing, the Board status table and the full 65-column field list: apify.com/flash_scraper/multi-jobboard-scraper. Run the Python call above once, open RUN_SUMMARY, and decide from the counts whether the feed is worth scheduling.

Top comments (0)