April 3, 2026/850 words/4 min read
Job Offers on Tap
Letting an LLM watch the job market so I don't have to.
The problem with job boards is that they're designed for people who are actively searching. You go to LinkedIn, type "product manager", then promptly drown in four hundred listings that range from interesting to downright insulting. My motivation is different. I mostly want to understand what companies expect from someone with my profile right now and what they're willing to pay.
The filtering is bad and relevance is worse because the whole experience is optimised for engagement. I wanted a quiet digest that showed me only the listings matching my preferences.
LinkedIn's job API is not publicly available in any useful way, but there are services that aggregate listings. I settled on SerpApi for Google Jobs results, which pulls from most major boards. It charges per search, but the free tier covers around 250 searches a month. I run a few targeted queries once a day, so that's more than enough for me. I supplement it with direct RSS feeds from company career pages I already keep an eye on and the monthly Hacker News "Who is Hiring?" threads. The HN threads surface the kind of roles I care about more than any recruiter-driven board ever has.
The scraper runs as a cron job on my home lab, the same Mac that runs everything else. Each source has its own fetcher, and everything normalises into a common shape before it hits the database.
interface RawListing {
title: string;
company: string;
location: string;
description: string;
url: string;
source: "serpapi" | "rss" | "hn";
scraped_at: string;
}The SerpApi query is intentionally broad. I'd rather collect too much and filter aggressively than miss something because I got too specific at the source.
const queries = [
"product manager fintech europe",
"product manager payments remote",
"senior PM developer tools europe",
"technical product manager ai tools",
];
const fetchSerpApi = async (query: string): Promise<RawListing[]> => {
const res = await fetch(
`https://serpapi.com/search.json?engine=google_jobs&q=${encodeURIComponent(query)}&api_key=${SERPAPI_KEY}`,
);
const data = await res.json();
return (data.jobs_results || []).map((job: any) => ({
title: job.title,
company: job.company_name,
location: job.location,
description: job.description,
url: job.related_links?.[0]?.link || "",
source: "serpapi" as const,
scraped_at: new Date().toISOString(),
}));
};For the HN threads I wrote a small parser that grabs the top-level comments, since each one is usually a company posting, and extracts the structured bits. HN posts are messy and inconsistent in format, but that's fine. The LLM handles the parsing later.
Once the scraper puts everything into SQLite, a second job asks Claude to score each new listing against a profile I keep in a YAML file.
# profile.yaml
role:
titles:
- "Product Manager"
- "Senior Product Manager"
- "Head of Product"
domains:
- payments
- fintech
- developer tools
- infrastructure
- AI/ML tooling
preferences:
location:
- remote
- hybrid
- Bucharest
- Europe
avoid:
- crypto/web3
- adtech
- gambling
signals:
positive:
- technical product work
- small to mid-size team
- developer-facing products
- design collaboration
- IC track available
negative:
- "manager of managers"
- enterprise sales focus
- requires MBA
- travel >30%The YAML is the whole personality of the filter. When I change what I care about, I edit this file and the pipeline adjusts without touching the code or redeploying it.
The scoring call is simple. Claude reads the listing description and the profile, then returns a structured assessment.
const scoreListing = async (
listing: RawListing,
profile: string,
): Promise<ScoredListing> => {
const client = new Anthropic();
const msg = await client.messages.create({
model: "claude-haiku-4-5",
max_tokens: 4096,
system: `You evaluate job listings against a candidate profile.
Score each listing 1-10 for relevance. Be harsh — a 7+ means the person
should genuinely consider this. Return JSON only. No explanation.
Format: {
"score": number,
"match_reasons": ["reason1", "reason2"],
"concerns": ["concern1"],
"one_liner": "Why this might be worth a look, in one sentence."
}`,
messages: [
{
role: "user",
content: `Profile:\n${profile}\n\nListing:\nTitle: ${listing.title}\nCompany: ${listing.company}\nLocation: ${listing.location}\nDescription: ${listing.description}`,
},
],
});
const result = JSON.parse(msg.content[0].text);
return { ...listing, ...result };
};"Be harsh" made the scoring useful. Early versions were way too generous and everything scored a 6 or higher, which made the digest entirely useless. Once I told it to treat 7 as the threshold for genuine consideration, most listings landed between 3 and 5, which is exactly where they belong. The ones that break through to 7+ are consistently interesting.
Every morning at 8am, a launchd job checks for new listings scored 7 or above since the last digest. If it finds any, it formats a short email and sends it to me via Resend. Otherwise it stays quiet. I don't want noise on days when there's nothing worth seeing.
const buildDigest = (listings: ScoredListing[]): string => {
const sorted = listings.sort((a, b) => b.score - a.score);
return sorted
.map(
(l) =>
`**${l.title}** at ${l.company} (${l.location})\n` +
`Score: ${l.score}/10 — ${l.one_liner}\n` +
`${l.match_reasons.map((r) => ` + ${r}`).join("\n")}\n` +
`${l.concerns.length ? l.concerns.map((c) => ` - ${c}`).join("\n") : ""}\n` +
`[View listing](${l.url})\n`,
)
.join("\n---\n\n");
};The digest is deliberately minimal. Claude's score tells me whether a listing deserves more time. If it does, the reasons and concerns explain why before I open the original. I can get through three or four while the coffee cools down. Most mornings none deserves a closer look, which is perfectly fine. If something good appears, it will be waiting in that same email.
Six weeks in, the market is more active than I assumed. I expected a handful of relevant listings per week and I'm getting maybe two or three per day that score above 5, with one or two per week breaking through to 7+. The European fintech space in particular seems to be hiring steadily, which is good to know even if I don't act on it.
Writing the profile YAML also forced me to articulate preferences I hadn't bothered to examine. I knew I didn't want adtech, but I hadn't realised how strongly I felt about staying on the IC track until I had to put it in writing. Building the filter clarified what I value. I didn't expect that from what started as a mechanical little tool.
The concerns field is where Claude earns its keep. Keyword matching would have been easy to build myself. Haiku is a smaller model, but it's surprisingly competent at filtering. It flags things like "description emphasises stakeholder management over product work" or "role requires coordinating across twelve teams, likely more process than craft". I'd catch those problems myself if I read every listing carefully, but I don't have time for that. Having them surfaced automatically saves me from opening listings that look good on the surface but aren't.
The whole thing costs almost nothing to run. SerpApi and Resend stay inside their free tiers, while the Anthropic API calls cost a few cents per day. I store the listings in SQLite and run everything on my home server. I haven't thought about scaling because there's nothing to scale.
I treat it as a market sensor, like the weather. I check the digest once in the morning and then get on with the day, without scrolling through LinkedIn pretending I wasn't looking.