DEV Community

Cover image for I crawl 217,000 company career pages so job boards can't resell me my own search
Ilya Strelov
Ilya Strelov

Posted on

I crawl 217,000 company career pages so job boards can't resell me my own search

Most job boards are resellers. A company posts a role on its own Greenhouse or Workday
page, and within a day that posting shows up on four aggregators, each with a tracking
link and an apply-through-us button. By the time you click, you are the fifth hop from
the person actually hiring, and nobody in the chain knows whether the role is still open.

I got tired of that and built freehire. Every listing comes from
the company's own applicant tracking system: Greenhouse, Lever, Ashby, Workday, iCIMS,
SmartRecruiters, Teamtailor, and a long tail of smaller ones. The link in the search
result is the company's own posting URL. No middle hop.

Right now that is 4,544,679 live postings from 217,034 companies. Both numbers come
straight off the public API, so you can check them without taking my word for it:

curl 'https://freehire.me/api/v1/jobs?limit=1'
curl 'https://freehire.me/api/v1/companies?limit=1'
Enter fullscreen mode Exit fullscreen mode

No key, no signup, no rate-limit wall on the first request. The whole thing is MIT
licensed and the pipeline is in the repo.

How it is built

Go with Fiber for the HTTP layer, Postgres for storage and filtering, sqlc for
type-safe queries generated from plain SQL (no ORM), Meilisearch for faceted search.
Nothing exotic.

The interesting decision is that ingest is not a daemon. Every worker runs once and
exits, driven by cron: crawl one board file, upsert what it found, exit non-zero if it
failed. That makes the failure mode legible. A stuck daemon is invisible for a week; a
oneshot unit that exits 1 shows up in the timer log the same hour.

The pipeline itself is deliberately boring. Fetch, normalize, dedup, upsert, enrich,
serve. Whatever the ATS, every adapter produces the same shape, so the rest of the
system never learns there are 160-odd of them. Dedup is a unique key on
(source, external_id) with an upsert on conflict. Enrichment (seniority, skills,
region, salary) drains a queue, and every dictionary is dict-only: if a term is not in
the dictionary, the field stays empty. The system never guesses a facet it cannot back up.

Adding a company is one line of YAML in sources/<provider>.yml. That is the extension
point on purpose. The core stays small and the surface grows.

The part that is actually hard

Boards die quietly.

A company migrates from Greenhouse to Ashby. Maybe the old slug 404s. Maybe it keeps
resolving and returns an empty list forever. Either way the crawler logs
ingested=0 failed=0, which is byte-for-byte what a healthy board looks like on a slow
day. Nothing throws. Nothing goes red. The board just stops producing, and if you watch
error rates instead of yield, you find out four months later when someone asks why that
company has not posted since spring.

There are currently 318 board slugs in the repo returning 404. Every one of them is a
company that moved somewhere and is still hiring. We just lost the thread.

Tracking them down needs no knowledge of the codebase at all: open the company's careers
page, see which ATS they are on now, change one line. It is
issue #1529 if you want to look at
the list.

Contributions are open now

Until yesterday this repo had an allowlist and a CI workflow that blocked pull requests
from anyone outside it. Both are gone. There is a CONTRIBUTING guide with exactly one
rule that matters: you have to understand your code. Writing it with AI is fine, this
repository is built with it. Submitting generated code you have not read is not.

On a few curated issues I am attaching a bounty paid in freehire credits, the same
currency the AI features on the hosted site run on: CV tailoring, fit analysis,
CV-to-vacancy scoring. Being straight about what that is worth: it is not money and I am
not going to pretend it is. It is worth real time savings if you are job hunting, and
nothing at all if you are not. Earned credits sit on top of the free monthly allowance
and do not expire, so they accumulate instead of evaporating at month end. I award them
by hand after a pull request merges.

Why credits and not cash. Cash bounties on open source have a well documented failure
mode: cURL shut its bug bounty down this year because maintainers were, in Daniel
Stenberg's words, effectively being DDoSed by low-quality AI-assisted submissions
chasing payouts. GitHub itself now pays swag rather than cash for low-severity findings.
A bounty in platform credits seems to land in a useful spot. Big enough to be worth an
evening if the product is useful to you, too small to attract people who are farming
payouts and do not care what they submit.

If that reasoning turns out to be wrong I would rather hear it than find out by inbox.

Poke at it

The API is unauthenticated and stays that way. If you want to build something on top of
four and a half million job postings, nothing is stopping you.

Top comments (0)