Data pipeline
Six job boards between them post more than I can read, and most of what they post isn't close.
Six scrapers pull postings into one archive, and a scoring pass ranks each one against the work I actually do.
The archive
Six sources, one place to look
Job boards don't agree on anything. Each one names its fields differently, paginates differently, and decides on its own when a posting is stale. Reading six of them means reading the same posting three times and missing the one that only went up on the board I skipped.
Six scrapers land everything in one Postgres archive with the full description kept intact, so a posting is searchable by any word in it rather than by whatever the board chose to tag it with. Company watchlists come off Ashby, Greenhouse and Lever directly, and the aggregators fill in what the watchlists miss.
Keeping the whole description matters because the scoring pass downstream reads position and repetition inside the text, and a truncated posting scores wrong.
The scoring pass
Reject fast, score slow
Scoring a posting properly means having a model read the whole description and judge it against what I can actually do. Running a 27B model over everything that lands costs more time and electricity than the search is worth, and the great majority of postings are decided long before judgment is needed.
So the cheap checks run first and the model runs last. A title screen throws out the obvious misses on 40-odd patterns in microseconds. A keyword pass parses the description into sections and weights terms by where they appear and how often, with a small dictionary that disambiguates words like "automation" that mean different things depending on the industry.
Six independent checks then vote on fit: remote, salary floor, role type, experience, red flags, and company stage. Two blockers is a rejection. Anything still alive with a keyword score under 30 is dropped before the model ever sees it, which is where most of the savings come from.
Postings scored
108,380
Six scrapers into one archive, scored on arrival
The four cheap gates share one total. The archive records them under a single decision, so splitting that into per-gate numbers would mean inventing them.
The scoring pipeline, phase by phase
async def score_job(job) -> dict:
jd_text = job.description_text or ""
title = job.title or ""
# -- Phase 1: Title screen (instant) --
screen = title_screen(title)
if not screen["pass"]:
return {**payload, "ollama_score": None, "fit_decision": "no-go",
"role_type": "rejected_title"}
# -- Phase 2: Keyword analysis (metadata, not authoritative) --
jd_analysis = await asyncio.to_thread(analyze_jd, jd_text)
keyword_score = jd_analysis.get("match_score", 0)
# -- Phase 3: Fit filter --
fit_result = await asyncio.to_thread(quick_fit_check, jd_text, jd_analysis)
block_count = fit_result.get("block_count", 0)
# -- Phase 3.5: Keyword score gate --
# If keyword_score < 30, the JD has almost no terminology overlap with
# the candidate profile. Don't waste Ollama time.
if keyword_score < 30:
return {**payload, "ollama_score": None, "final_score": keyword_score,
"fast_reject_reason": f"keyword_score {keyword_score} below 30 threshold"}
# -- Phase 4: Fast rejection (fit filter blockers) --
if block_count >= 2:
return {**payload, "ollama_score": None, "final_score": 0,
"fast_reject_reason": f"{block_count} blocking issues"}
# -- Phase 5: Ollama gatekeeper (the authority) --
ollama_score = None
ollama_result = await score_with_ollama(jd_text)
if ollama_result:
ollama_score = ollama_result["score"]
# -- Phase 6: Final score logic --
if ollama_score is not None:
final_score = ollama_score # Ollama is the authority
else:
final_score = keyword_score # keyword fallback if it is down
The judgment call
The keyword score opens the door, the model decides
A keyword score is a proxy. It rewards a posting for using the vocabulary I use, which catches the obvious matches and also catches every posting written by someone who likes the same words. It can't tell the difference between a role that needs the work I do and a role that merely describes it.
What clears the gate goes to a local model that reads the description and makes the final call, and its answer outranks the keyword score rather than averaging with it. A reconciliation pass writes one number back to the posting so the archive stays sortable, and anything scoring 70 or better shows up in Discord while I'm doing something else.
From there a second machine takes over. It pulls company research and the job description apart, picks the evidence that matches, and drafts the resume and cover letter against a database of things I have actually done.
Where it stands
Running since April 2026, and the archive holds more than 108,000 postings. More than 60,000 of them were rejected by the cheap checks and never cost a model call. 239 so far have scored 70 or better.
FastAPI · PostgreSQL · local 27B model · two machines