The problem
Job boards match on keywords, so the search that describes what you actually want returns hundreds of near-misses and buries the handful that fit. Aggregators make it worse: they add a layer of stale, duplicated listings between you and the company that's hiring.
So JobHunter skips the aggregators. It scrapes company career pages directly across 12 applicant-tracking systems, scores every posting against a rubric built from one specific résumé, and lets you search by meaning rather than by keyword. It runs on free tiers, which turned out to be the most interesting constraint in the project.
The pipeline
EventBridge triggers an orchestrator Lambda four times a day. The orchestrator reads curated company lists — about 16,700 companies, discovered by querying the Common Crawl index for ATS URLs — and fans out to roughly 420 ingestion workers, each handling a batch of 40 companies. Workers scrape, enrich descriptions, filter for Canadian roles, and upsert into Supabase.
A scoring Lambda then picks up whatever is unscored, sends it to MiniMax-M3 in batches of ten, writes the results, and self-chains until the backlog is clear — with a cap on chained invocations so a bad day can't turn into a runaway loop.
The twelve sources are Greenhouse, Lever, Ashby, SmartRecruiters, Workable, Rippling, iCIMS, Pinpoint, Teamtailor, Breezy, YC's Work at a Startup, and WeWorkRemotely. Ingestion runs on ARM64 Python 3.12; the whole backend is three Lambdas.
The rubric
Every posting is scored 0–100 across four weighted categories: Role Fit (35), Seniority Fit (30), Stack Overlap (20), and Keyword Relevance (15). The model returns structured JSON — the sub-scores, matched skills, and a one-line rationale.
The weighting is the opinion in the system. Role and seniority together carry 65 of the 100 points because a perfect stack match on a staff-level role is useless to someone who can't apply for it; stack overlap is worth less than it feels like it should be, because stacks are learnable and titles are gatekept.
Below 25 a job is marked scored and never surfaces. At 25+ it becomes matched.
The board shows above 60. A location filter keeps only Canadian roles, which is a
work-authorization constraint, not a preference.
Searching by meaning
Scoring answers "is this job good for me?" Search answers "is this the job I'm thinking of?" — and keyword matching is bad at the second question.
Each posting is embedded with OpenAI text-embedding-3-small and stored in a
pgvector column. A match_jobs Postgres RPC does the cosine comparison
server-side, applies the location, company, platform, category, level, and date
filters in the same query, and returns a similarity score per row.
The first version of that function was quietly broken in three ways, and fixing it was the most useful thing I did on this project:
- It never actually ranked by similarity.
match_jobsfiltered on a cosine threshold but then ordered byposted_at, so results were recency-sorted inside a relevance filter — close enough to look right, wrong enough to be useless. The IVFFlat index was never used either, because the ordering never asked for it. - Every search ran the scan twice. A companion
count_matched_jobsfunction re-ran the entire cosine comparison just to produce a total for pagination. That is now a window function in the single query. - The scores join was unscoped. With more than one scoring model per job, the join could duplicate rows. It's now pinned to a single model.
The index got dropped rather than rebuilt. It had been built on an empty table, so its centroids were degenerate, and at free-tier scale an exact scan is both fast and always correct — a rebuild would also have risked the same memory ceiling described below. Removing an index made search faster and more correct.
Why $0/month is a design constraint
Every free tier is a limit that has to show up somewhere in the architecture:
- Embeddings are 256 dimensions, not 1536. Supabase's free tier caps
maintenance_work_memat 32 MB, which is what building a vector index on larger embeddings needs to exceed. The dimension count is a memory budget, not a quality decision. - Descriptions are nulled after scoring. The free database is 500 MB. Full job
descriptions are the largest column and are worthless once a score exists, so the
scoring Lambda discards them on the way out. Anything that somehow arrives without
a description gets marked
scoredrather than sent to the model blind. pg_cronpurges continuously. Expired jobs and old scrape-run rows — about 1,600 a day — are deleted on a schedule, with guardrails in a migration so a purge can't run away.- Lambda's free million requests is why ingestion fans out into 420 short workers instead of one long job, and why scoring self-chains rather than running on a timer.
Total cost: Lambda $0, Supabase $0, Vercel $0, MiniMax $0.
The board
Next.js with server-side rendering, debounced filters, and pagination through the same RPC. Queries are wrapped in a small retry helper, because free-tier Postgres pauses when idle and the first request after a quiet period can fail for no reason worth surfacing to a user. One retry after a short delay converts almost all of those into a normal page load.
PostgREST's filter grammar treats ,, (, ), and . as delimiters, so raw user
input in an .or() clause silently corrupts the filter. Search values are quoted
and ILIKE wildcards escaped before they get anywhere near a query.
What I'd change
The scoring rubric has never been validated against anything but my own agreement with it. I read the top of the list, it looks right, and that's the entire evaluation — which is exactly the kind of unmeasured judgment the project exists to replace. A held-out set of postings I'd graded by hand first would tell me whether the weights are correct or merely plausible.
The single-model assumption is also now load-bearing in more places than it should be, including that pinned join.