Tom Girón-Littler
← Builds

Clara Jobs

Clara Jobs is a daily-refreshed, ranked feed of European academic jobs, scored by Claude against one specific researcher's profile, so that checking one page every morning replaces checking a dozen terrible university job boards. The researcher is my wife. The academic job market is brutal and the tooling for it is worse, so I built her a robot.

The ranked feed: score, one-line rationale, filters

The pipeline is five steps: scrape, SQLite, a dumb rules filter, a Haiku score, a static page. A GitHub Action runs it at 06:00 UTC and the whole thing costs pennies a day. The interesting bits are where each step earned its place.

Scraping EURAXESS, or: the tags are lying to you

The main source is EURAXESS, the EU's research jobs portal. No API, server-rendered HTML, BeautifulSoup. The first version searched by EURAXESS's own research-field facets, which sounds sensible until you look at the results: a listing tagged "Sociology" is quite often a medical radiation lectureship, and a "Criminology" search returns urban environmental science. The tags are applied by whoever posts the job, which is to say, optimistically.

The fix was to abandon the taxonomy and search by 21 curated keyword queries instead, terms specific enough that they can't be misapplied ("necropolitics" does not appear in radiation lectureships). That one change took the feed from zero listings scoring above 6 to a UK fellowship at 15.1 with 23 ranked items behind it. It's the biggest single lesson of the project: when a site's metadata is user-generated, the free-text search is more honest than the structured filters.

EURAXESS also rate-limits aggressively. At a 2-second delay between requests, 5 of 7 daily runs died on HTTP 429 having added nothing. Now it's 4 seconds, every failure keeps its partial results instead of throwing them away, and, my favourite small trick in the codebase, the keyword list is shuffled with a random seed derived from today's date. If a run gets cut off halfway, tomorrow starts from a different place in the list, so over a week every term gets covered even if no single run survives.

Spend nothing before you spend something

Everything is ordered by cost. Already-seen job IDs are skipped before their detail pages are even fetched, keeping their old scores. Then a rules filter runs before any model call: a plain substring blocklist, no cleverness, and it removes 79.5% of listings, 245 of the 308 currently in the database. The top rejection reason is "phd position" (62 times), because half of European job boards can't tell a postdoc from a PhD studentship.

The blocklist has one subtlety I'm fond of: "doctoral fellow" is deliberately not on it. I tried adding it and it started falsely killing postdoc listings that merely mention the term in an eligibility paragraph, including the best fellowship in the whole feed. A filter's false positives are invisible unless you go digging, which is exactly why the dumb layer has 9 tests and the clever layer gets audited by hand.

Only the surviving 20% reach Haiku.

The prompt is a person

The system prompt isn't "you are a helpful job-matching assistant". It's a full profile of one researcher: her thesis area, her methods, her languages, the explicit note that she has no quantitative training (so anything requiring "questionnaire design" gets marked down), and the fact that we're moving to York, so York roles get a heavy thumb on the scale.

The model is forced to call a single tool, record_score, with a strict schema: a topic score, a role-shape score (independent fellowship scores high, "coordinator" scores zero), a proposal-shape enum, and a rationale capped at 25 words, which is the one-liner you see under each job in the feed. Pydantic validates the output with bounds the JSON schema can't express and retries once if the model misbehaves. Then deterministic code takes over: composite = topic × 0.6 + role-shape × 0.4, times a location boost (York is 3.0), times a deadline nudge (closing within 14 days boosts it; expired multiplies by zero and vanishes).

The calibration surprised me. The spec assumed good jobs would score 8+, so the UI's default filter was 8, and it hid almost everything worth seeing. Real scores cluster lower, most decent non-York roles land between 4 and 8, and the filter now defaults to 4. The rubric in my head and the one the model actually applies turned out to be different distributions, and you only find that out from the data.

The database is a file in the repo

There's no hosted database. The SQLite file (currently 2.3MB) lives inside the Next.js app directory, the Action commits it back to the repo every morning, and that commit triggers the Vercel rebuild, where the pages read it at build time with Node's built-in SQLite and render fully static. No servers, no read costs, and every day's feed is a git commit you can diff.

This worked on the second try. On the first, I'd stored the DB in Git LFS, Vercel's build pulled the LFS pointer instead of the file, and SQLite said "file is not a database". Twenty-four minutes between the v1 commit and that fix, which I think is the fastest a piece of infrastructure has ever humbled me.

Still v1: one source (jobs.ac.uk is next, the scraper base class makes new sources a drop-in), and an honest confession from the code comments — the prompt-caching marker on the scoring call silently does nothing, because Haiku's minimum cacheable prefix is 4,096 tokens and the profile prompt is shorter. Harmless at this volume. It ranks her mornings correctly, which was the whole job.