jobhunter
The itch
Job hunting now mostly returns silence, and the way through it is volume. You fire a search, wait, then scan dozens of postings deciding in a few seconds which are even worth opening. Doing that on LinkedIn means reading each posting in full to work out the real requirements, then guessing how well your resume lines up. jobhunter came out of wanting that triage compressed: search once, get every posting scored against your actual resume, and spend attention only on the ones worth it.
What it does
jobhunter searches LinkedIn without a login, through JobSpy, reads each job description, and uses Gemini to pull out the structured requirements: years of experience, skills, seniority, remote or onsite. It scores every posting against an uploaded resume, so a search comes back ranked by fit rather than by recency. Alongside that it runs an ATS-friendliness check on the resume and, for any single job, produces tailoring suggestions. It only ever suggests. It never edits your resume file. Saved, applied and rejected states are tracked per job.
It's multi-user. You sign in with Google, and your resume, searches and tracked applications are visible only to you.
How it works
The backend is FastAPI over Postgres, raw SQL rather than an ORM, every user-facing query filtered by user_id and backed by Row Level Security. The frontend is server-rendered Jinja and htmx with no build step. A search doesn't run inside the request that starts it: the request inserts a pending row and returns, and a worker claims it later, so a search survives a restart and a slow scrape never blocks the page. The searches table doubles as the queue, since it already carries the status column a separate queue would only have to stay in step with.
Postings are shared across users and extracted once, ever. The expensive part of a search is the LLM call per posting, so a posting a previous search already turned up is reused rather than re-extracted. Only scoring stays per user, because it depends on their resume.
What got hard along the way
The first surprise was the LLM quota. Google's gemini-flash-latest alias tracks the newest model, and the newest model carries the tightest free-tier limit. Measured against the project's own key, it resolved to a model capped at 20 requests a day, less than two searches, while an older lite model was limited per minute instead and ran about thirteen times faster on the extraction prompt. Pinning to the lite model, rather than the one that sounds the most current, is what makes the free tier usable at all.
The second was a database wall hit while building posting dedupe. Dedupe needs INSERT ... ON CONFLICT ... RETURNING id to get the row back whether it was just inserted or already there. But RETURNING runs through the table's row-level-security read policy, and at insert time there's no row yet linking the posting to the user, so the policy refuses to read the new row back and the insert fails outright. A plain INSERT worked; the same INSERT with RETURNING did not. The fix was to make postings readable by any signed-in user, which is safe because a posting holds only public job-ad text, and to keep the per-user scoping at the route level instead.
The design took two attempts. The first was built and rejected on sight: warm cream paper, a serif display face, earthy status stamps. It was the exact "an AI designed this" cluster, and worse, the wrong register. Editorial calm suits an essay, but this is a dense tool for grinding through dozens of rows. The replacement is built around the score column, which reads as a bar chart of the whole search when it's sorted, with keyboard triage and no red anywhere, because alarm-red on the fortieth rejection is a tone failure, not a legibility win.
Where it stands
A lot already works: search, scoring, the ATS check, tailoring, application tracking, Google auth and per-user isolation, with 114 tests and a Docker-plus-Caddy setup ready for a small always-free VM. It's a private repo with no public instance yet, and a plan file holds the phases still ahead, among them a durable job queue, object storage, and billing. The open question is scoring calibration: relative ranking is sound, but a top score of 53 can read as "poor" rather than "best of what's here", and fixing that means an LLM scoring call per posting, which can't be deduped the way extraction is.