Jobzyl
One search across 20 job boards, with ATS resume matching

- Timeline
- Jun 2026 - present
- Role
- Solo build - sole designer and engineer
- Status
- Shipped
- Primary stack
- Next.js · FastAPI · Supabase · AWS
Headline metrics
- What it tackles
- Searching for a job across the major boards is a data-collection chore before it is a job search: different filters, different refresh cadences, the same role listed under four different URLs, and no visibility into how your CV scores.
- What it delivers
- One search across 20 job boards and thousands of company careers pages over an index of more than 2M live postings, deduplicated across sources, with a pay estimate only where the employer stated nothing, and a seven-stage tracker for everything applied to.
Why I built it
I built this one for myself first. I was job hunting, and every search meant opening the same handful of boards, scrolling past the same reposted listings, and losing track of what I had already seen. The searching was taking longer than the applying.
The part that actually bothered me was the second half: after all that, I still had no idea whether my CV would get past the screener. You send it, and you hear nothing, and you never find out which of the two things went wrong.
So Jobzyl answers both. One search instead of twenty tabs, and a match score against the job description before you apply, so you know where you stand rather than guessing.
The problem in full
Job search across the major boards is a full-time data-collection job before it is a job-search activity. Each platform has different filters, different update cadences, and different opacity around how its scoring works against your CV. The same role appears on four of them under four URLs, and none of them tell you that.
The interesting engineering is not fetching listings. It is what you are willing to say about them once you have. An aggregator inherits every gap in every upstream source: a listing with no stated pay, a listing that was filled three weeks ago, a figure that is a day rate wearing an annual costume, a salary that is not the employer's number at all but the board's own model output. Passing that through to a job seeker as though it were fact is the easy version, and it is the one that makes the product worthless.
So the constraint I built to is narrow: never state something the data does not support. That one rule is what most of the difficult code in this project is enforcing.
System design
Ingest fans out across 23 implemented providers behind a single search() interface, of which 20 boards are disclosed publicly, plus 7 ATS platforms (Greenhouse, Lever, Ashby, SmartRecruiters, Workable, Recruitee, Teamtailor) ingested directly so a company's own careers board is a source rather than an aggregator's copy of it. No ATS publishes a company index, so discovery is slug guessing: a 68.6% hit rate over 140 company names in 29.8 seconds, then self-pruning bands that stop once three consecutive bands of 500 names come back under a 15% yield floor. The fan-out is parallel with a per-provider timeout so one slow board cannot hold the response, and a provider that catches its own transport error and returns an empty list is banned at the base class, because that shape reports 100% success forever.
Two schedules keep it fresh. A 6-hourly scrape, and a 30-minute queue tick that claims work under leases from a queue of keyword-by-country pairs, roughly 968 pairs a day, where pairs that keep coming back empty retire themselves. On top of that sits a weekly breadth sweep sized by a call budget rather than a clock and weighted by how much depth a country can support: one with a real provider locale gets the full keyword library, one reachable only through a single global board gets about 113 breadth-first keywords. The thin tier is reserved first, because spending deep-first once burned a 40,000-call budget on 19 locations and left 90 countries with nothing.
Storage is self-hosted Supabase: 23 tables, 43 migrations, row-level security on every public table. Writes go through an upsert RPC chunked internally on both a row count and a byte bound, because PostgREST connects with an 8-second statement timeout and one 10,350-row call died and took a whole sweep with it. Rows rotate into an archive table at 30 days on coalesce(date_posted, created_at), so the 40% of listings that state no date cannot dodge retention, and a job someone saved is never archived.
Search is SSE over POST, streaming per-provider progress so the reader can watch boards answer instead of watching a spinner. Anonymous visitors get the cache; only the live fan-out needs an account, and that gate sits on the pipeline step rather than on one branch, because there are three ways in. Ranking lives in a Postgres function rather than the query builder, over a full-text index that weights the title above the description. Eleven filter dimensions carry facet counts that apply every filter except the one being counted, which is the only way the number beside an option answers the question the reader is actually asking: what do I get if I click this.
Matching is three separate paths, deliberately not one. Keyword ATS extraction parses the CV in the browser and uploads nothing. Semantic similarity scores a saved CV embedding against job embeddings in pgvector. Claude Haiku scores individual jobs under a per-user daily quota. The second and third need an account and a stored CV, which is Fernet-encrypted at rest, and the backend refuses to start in production without the key.
Key technical decisions
Drop a pay figure rather than reinterpret it
Zero is dropped: it means the provider had nothing, never that the job is unpaid. A figure whose annual equivalent is implausible is dropped too, not converted. I know GBP 450 is not an annual salary, but I do not know whether it is a day rate, an hourly rate or a typo, and guessing daily would replace a wrong fact with an invented one. Pay renders in the period the employer quoted, or it does not render.
Exclude one provider's predicted salaries
Adzuna flags when a figure is its own market estimate rather than the employer's. Measured on 2026-08-18, 49 of 50 US data-scientist rows that carried pay were predicted. No plausibility check catches that, because a model's output is plausible by construction, so those figures were rendering as quotes under a promise of employer-stated pay. They are out of aggregation entirely now, and styled distinctly where they still appear.
Only positive evidence marks a listing dead
Nothing revalidated a stored posting until August. On the newest 40 rows per site, 12.1% of what was being served was already gone, and on two boards it was over half. So a sweep reprobes stored URLs, but a 403 or a timeout writes nothing at all: 170 of 593 probes were refusals, and treating a refusal as an answer would have buried 168,238 live rows in a single pass. Going faster makes it worse rather than better. At concurrency 10, 126 of 200 probes came back unknown.
Dedupe at write time and on every read
A duplicate is the same role under a different URL, so the identity key is accent-folded and punctuation-stripped with company legal suffixes dropped. Write-time dedup can only ever see one batch, which is why it runs on the read paths too. 61,563 redundant rows collapsed, and the losing copies are recorded rather than deleted, so a row can say which other boards it appeared on.
Ranking in a Postgres function, not the query builder
The shape this replaced shipped 27.4% of results with none of the user's search words in the job title. The fix is a full-text index weighting the title above the description, and those weights are only read by ts_rank_cd, so the weighting and the ranking function are one change in two files and neither does anything alone. There is no paid placement in the ranking: sponsorship came out of the function in a migration.
Keyword scoring in the browser, everything else opt-in
Server-side scoring would cache better and extract more, but it makes a stored CV the default rather than a choice. Keeping the keyword path local makes the data minimisation structural rather than a policy promise, and the two paths that genuinely need a stored CV ask for one explicitly instead of quietly assuming it.
SSE over WebSocket
Search progress is a one-way push from server to client. SSE is a single HTTP request, reconnects on its own, and skips the upgrade dance. A WebSocket would be over-specified for a stream that never carries anything upstream.
The results in full
More than 2M live postings after the 2026-08-19 market sweep, over a taxonomy of 1,173 keywords in 42 groups across 109 countries. On the census two days earlier, 82.7% of the corpus had been posted within the previous 30 days. 20 boards are named publicly, 23 providers are implemented, and 7 ATS platforms are ingested directly.
The board count has grown from six at launch to twenty today with no architectural change. The parallel, rate-limited fan-out absorbed every new source, which is the strongest evidence that the ingest shape was right.
The integrity work is the part I would actually defend. 61,563 duplicate rows collapsed on the shared identity key. Two thirds of listings state no pay at all, so the estimate speaks only where the employer did not, is a lookup over measured cells rather than a model, and has no country-wide fallback, because a national median tells a nurse nothing about nursing. Cells are deduplicated per job before they count: one poster ran the same role 220 times at an identical figure, and on raw row counts that one poster would have set a UK salary cell that every UK engineer reading it would have taken as fact.
Operationally: 23 RLS-locked tables over 43 migrations, 1,308 backend tests across 91 files, PKCE OAuth for Google, LinkedIn and GitHub, header-only admin auth with an audit log that escalates to Sentry if its own write fails, and 200 generated category pages of which 2.0% ship noindexed against a build gate set at 15%.
What I'd do next
The claim I most want to correct is one I made myself. This page used to say 2M postings, 60+ countries, and first results in about 1.4 seconds, and not one of the three had been measured. The index has since passed 2M for real, on a sweep with a date on it, which is the least comfortable version of being right: the number was a guess that happened to land near the truth, and a guess that lands is still a guess. The other two are withdrawn. The country figure came from counting distinct search-location strings, so it was counting query regions and multi-counting any country I had searched under several city names, and it has been pulled from the product as well. Nothing has timed the first-result path since June, so there is no timing figure on this page.
Two things the numbers above deliberately do not say. Coverage is uneven: on the last census five sources accounted for 77.8% of everything and six of the twenty disclosed boards had contributed no rows at all, so the honest verb is that a search queries up to twenty boards, not that the index draws on twenty evenly. And liveness had been probed on 0.32% of the corpus. The filter ships; the coverage does not exist yet, and claiming otherwise would be exactly the failure this project is built to avoid.
What is next is the history table. Every dedupe cluster already records first-seen and repost count, with no route and no UI, built now because history cannot be backfilled from nothing. Telling someone a role has been advertised four times since March is the signal job seekers currently keep by hand in spreadsheets, and in a year the data will be there to say it.
Continue reading
Autonomous Voice Agent
Cutting voice-agent latency 54%, from 2.4s to 1.1s
FinLaw-UK
Graph-augmented RAG for UK financial regulation
