writing

I scored 633 job listings with an LLM on my homelab

A Python scraper collected listings, a 30B LLM on a remote GPU scored them, a Flask dashboard showed the results, and Claude generated tailored LaTeX resumes. The pipeline scraped 633 jobs and scored 104 of them at 7/10 or above.

Update, September 2026: I retired this pipeline after I started at UofT Libraries. The post describes it as it ran.

Job applications are tedious because every one is slow and repetitive. You read a description, decide whether it’s worth applying, tailor the resume, submit, and then do the same thing two hundred more times. At some point I decided to automate the parts a computer does better.

So far the pipeline has scraped 633 jobs and scored 104 of them at 7/10 or above. This post covers how it works.

Scraping

I use a Python library called python-jobspy to pull listings from LinkedIn, Indeed, and Glassdoor. It returns everything as a structured table: job title, company, description, URL, location. You give it search terms and it handles the rest.

A scheduled task runs it every morning at 7 AM on Nexus, my home server. Results go into a local database.

I hit one dependency problem. python-jobspy pins older versions of a data library that conflicts with newer versions of another. The fix is to not pin the dependency yourself and let the package manager resolve what’s compatible. I spent longer on this than makes sense.

Scoring

Each job description gets scored by a large language model, specifically Qwen3 with 30 billion parameters, running on a Windows machine with a GPU that’s connected to Nexus over the private VPN. The model reads the description alongside a summary of my background and returns a score from 1 to 10 with a one-sentence reason.

I tried a smaller 7-billion-parameter model first. It wasn’t discerning enough. It’d rate jobs as strong matches when they clearly weren’t, and score real fits low. The larger model reads the description carefully. The difference between "Python backend engineer" and "Python data scientist" matters when you’re filtering 600 listings down to something manageable.

One bug is worth mentioning. There’s an option to force the model to respond in JSON format. It kept producing malformed JSON. Turning off that option and just asking for JSON in the prompt itself fixed it. The model produces clean output when you specify the format in the instructions.

The dashboard

Results show up on a simple internal website running on Nexus. Jobs are sorted by score, with filters for minimum score, source site, and whether a resume has already been generated. Click a job to read the full description and open the generated PDF.

It’s exposed publicly. Basic auth is on the list.

Resume generation

This is the part that took the most trial and error.

Claude Code (the AI coding tool I use) has a mode that lets you pipe a prompt in and get a response back without an interactive session, which makes it scriptable. I use it to generate resume content for each job:

result = subprocess.run(
    [claude_path, "--print"],
    input=prompt,
    capture_output=True,
    text=True,
    timeout=600,
)

The prompt includes the job description and my profile. Claude outputs structured data with all the resume fields: skills, experience bullets, projects, coursework tailored to the role. A Python script assembles that into a LaTeX document (a typesetting format for producing clean PDFs) and compiles it.

An earlier version asked Claude to write the LaTeX directly. It kept producing code that didn’t compile. LaTeX is unforgiving about syntax. Having it output structured data and doing the assembly in Python is much more reliable.

If the PDF runs longer than one page, the script trims it automatically: reduce bullets per job entry, drop the least relevant project, tighten further if still over. Three passes have been enough every time. Getting that trimming logic right took longer than anything else in the project.

How the pieces connect

Internet
  -> VPS (Oracle Cloud, free tier)
    -> Nexus (home server)
      -> pipeline container: scraper, scorer, Flask dashboard
      -> generator: runs directly on the host
        -> GPU machine (Windows, via VPN)
          -> Ollama running Qwen3

The scraper and scorer run inside Docker containers on Nexus. The resume generator runs directly on the host because it needs Claude Code installed, and I didn’t want to add that to a Python container. The GPU machine that runs the AI model is connected over the VPN and reachable at a fixed address.

Results

The 7/10 cutoff works well as a filter. A "Machine Learning Engineer, 5+ years C++ required" scores a 3. A "Backend Software Engineer, Python, distributed systems" scores an 8.

Each resume takes 30 to 60 seconds to generate. With 104 jobs in the queue and a usage limit that resets every few hours, it runs across a few sessions. That’s slow, but I’m not doing it by hand either.

The main thing I’d change is running a smaller, faster model first to filter out obvious mismatches before sending anything to the large model. Right now everything goes to the 30B model, which works but takes longer than it needs to. I’m also still manually restarting the generator after each rate limit reset. That should be automated.


Code is on GitHub: llm-job-pipeline

about the author

Prahlad Ranjit, AI Applications Developer at UofT Libraries.

prahlad.ranjit7@gmail.com · résumé · project: X1 Local LLM Server

Set in Newsreader and IBM Plex Mono. Toronto, ON, Canada.

© 2026 Prahlad Ranjit