A job-hunting automation pipeline: scrapers find roles, an LLM ranks how well each one fits your resume, and a published Chrome extension fills the applications. Built because applying to jobs is a job nobody should do by hand.
Job hunting is a funnel problem: the more relevant roles you apply to, the better your odds, but every application costs ten minutes of copy-pasting the same information into a slightly different form. The obvious answer is automation, and the interesting part is where automation actually works and where it does not.
So I split the problem honestly. Servers are good at finding and ranking jobs, terrible at submitting forms behind logins and anti-bot walls. Browsers are the opposite. JobPortal puts each half where it belongs.
Playwright-based workers scrape job boards on a schedule, normalize listings, and dedupe them by title and company over a rolling 30-day window.
An LLM layer scores each scraped job against the target roles on your resume, so the queue surfaces the roles worth your time first.
The pipeline enqueues application jobs as database rows with full status tracking, keeping the server-side flow observable and replayable.
The extension on the Chrome Web Store performs the actual submissions in your logged-in browser, with a dedicated flow for Indeed's apply forms.
A Next.js dashboard shows scrape runs, queue state and application history, polling live status so you can watch the pipeline work.
One chat() wrapper routes across DeepSeek, Gemini and Anthropic by key presence; on failure it degrades to keyword and template logic instead of breaking the pipeline.
A FastAPI service on Fly.io owns scraping, ranking and the queue. The Next.js dashboard on Vercel is the control panel. The Chrome extension is the arm that touches job boards, because the user's real browser session is the only place applications can be submitted reliably and legitimately.
Stack: Python (FastAPI), Playwright, PostgreSQL, Next.js + TypeScript, Chrome Extension (Manifest V3), DeepSeek / Gemini / Anthropic APIs, Fly.io, Vercel.
Scrape runs defaulted their status to "running", so when a worker died mid-run the row stayed running forever and every future scrape refused to start, thinking one was already in flight. The fix was defense in depth: a startup reset in the app lifespan that clears wedged rows, plus a 30-minute stale guard in the scraper itself. State machines need an answer for processes that die between transitions.
Fly.io auto-stops idle machines, which is great for cost and terrible for a long scrape: the machine can be reaped mid-run. That is exactly how the stuck-run bug got triggered in production. The stale-guard design above made the pipeline self-healing regardless of why a run dies, instead of trying to fight the platform's lifecycle.
Auto-submitting applications from a server means fighting logins, sessions and anti-bot systems on someone else's site, a battle you lose slowly and then suddenly. So the server side only enqueues work, and the extension submits from the user's own logged-in browser, with a dedicated script for Indeed. LinkedIn is deliberately never auto-applied. Knowing where automation should stop is a design decision, not a limitation.
All AI calls route through one chat() wrapper with a provider priority chain (DeepSeek, then Gemini, then Anthropic, chosen by key presence). On any error it returns an empty result and the caller falls back to keyword and template logic. A pre-filter also skips the LLM entirely when a resume has no target roles set, so tokens are never spent scoring against nothing.
A published extension means real release engineering: a version shipped pointing at an API host that later changed, so the update needed both the new API base and a storage migration, packaged with the manifest at the zip root, submitted, and then waited on Google's review before users healed. Browser extensions are distributed software, not web deploys; you plan releases around a review queue you do not control.