rodadoandClaude Sonnet 5 821bd19ff7 feat: add Switzerland-wide remote sweep for core keywords
Core KEYWORDS only ran onsite/hybrid against Basel/Bern/Zürich; a manual
remote fullstack search turned up viable postings (PAS01, xorlab, Proton,
SCOR) that none of the existing searches covered.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zgfzuFy3bAbs8GTh9apFc
2026-09-10 12:15:46 +02:00

linkme

A personal job-hunting pipeline for the Swiss market. It scrapes LinkedIn (plus a handful of Swiss boards), filters out everything that doesn't fit the profile in cv/polo.md, appends what survives to a CSV, and pushes the good ones to Telegram twice a day.

Two things run on their own once installed:

automation when what it does
daily digest 08:00 + 18:00 fetch every search, push new profile-matching jobs to Telegram
sciCORE watch 08:47 alert if sciCORE's open-positions page lists a software role

Quick start

bun fetch      # scrape all searches, append new jobs to results/jobs.csv
bun daily      # fetch, then Telegram the new jobs that match the profile
bun recheck    # show what the filters are currently dropping + keyword suggestions

bun run mint-datadome   # refresh the basel-jobs.ch cookie (opens a Chrome window)

bun fetch needs no setup — reruns are safe, already-seen jobs are skipped by ID, and every search/source runs concurrently (a full run is well under 2 minutes). Telegram needs the two-line setup below.

Telegram setup

# 1. Message @BotFather → /newbot → copy the token into .env
echo 'TELEGRAM_BOT=<token>' >> .env

# 2. Send your bot any message ("hi"), then discover your chat ID
bun run telegram.ts --setup      # prints TELEGRAM_CHAT_ID=... — paste it into .env

Then bun daily --dry-run prints the digest instead of sending it, and bun daily --resend re-sends today's jobs from the CSV without re-fetching.

.env also holds LI_AT, a LinkedIn session cookie used only for fetching individual profile pages. Never send it with a job search — with the cookie LinkedIn serves a React SPA instead of server-rendered HTML and the card parser gets nothing. .env is gitignored.

Structure

config.ts          # keyword families, search areas, the sweep() generator
fetch.ts           # LinkedIn scraper, HTML parser, all title/content filters
fetch-other.ts     # the eight non-LinkedIn sources (see Sources below)
index.ts           # orchestrator: run searches → dedupe → content-check → CSV
recheck.ts         # raw-mode fetch: lists what the filters dropped
daily.ts           # digest: fetch, match against the profile, send to Telegram
profile.ts         # title-level scoring — decides what's worth a notification
telegram.ts        # Telegram delivery + --setup chat-ID discovery
scripts/           # one-off searches (*-temp.ts) and scicore-watch.sh
cv/polo.md         # the profile everything is ranked against
results/jobs.csv   # output, one row per job
results/sent.txt   # links already pushed — never sent twice

Sources

LinkedIn is swept by config.ts's search families. Everything else lives in fetch-other.ts and runs concurrently with it — a full bun fetch stays under two minutes.

source scope notes
LinkedIn 23 searches keyword families × areas, generated by sweep()
job-room.ch Switzerland the federal employment service board (RAV); by far the largest feed
basel-jobs.ch Basel region behind DataDome — needs a cookie, see below
jobbasel.ch Basel region React SPA, but /job/suche-{slug} is server-rendered with a window.__PRELOADED_STATE__ JSON blob
englishjobsearch.ch BS + BL cantons any page accepts ?format=markdown and returns clean server-rendered markdown
itjobs.ch Switzerland small (~210 jobs); locking it to Basel returned only ~9, so it's swept nationally
datacareer.ch Basel 8 jobs on one page, no pagination
stellen-basel.ch Basel region WordPress REST API
Basel intl orgs Basel BIS (via the UNjobs mirror), Swiss TPH, Basel Institute on Governance

Two of these were dead until recently and are worth knowing about:

datacareer.ch spent months returning 403 on every request. The cause was our own User-Agent — its nginx inverts the usual bot rule and blocks browser-like UAs while serving honest ones (no UA, curl, Googlebot). It gets PLAIN_HEADERS instead of the shared spoofed-Chrome ones.

basel-jobs.ch sits behind DataDome. Plain fetch gets 403 on any UA, headless Chrome over CDP gets 403, and Playwright's own launch() gets 403 even headful — but a real Chrome launched normally with --remote-debugging-port and attached to afterwards gets through. Clearance then lives entirely in a datadome cookie that replays fine over plain fetch, so a browser is only ever needed to mint, never to scrape:

bun run mint-datadome    # opens a real Chrome window for ~10s, caches the cookie

The cache is results/.datadome.json (gitignored). Because minting needs a visible window, a launchd run can't do it — if the cookie goes stale the source logs unavailable and yields nothing while every other source continues, so the digest never fails over it. Set LINKME_NO_BROWSER=1 to suppress minting entirely.

It's scraped in three passes, all of which cover different sets: the informatik-jobs category is a curated taxonomy rather than the whole board, and the two search modes query different indexes — ?terms=X&websearch=1 finds role words and postings filed outside IT, while the bare ?terms=X is the only mode that matches stack terms (react, frontend and backend return literally zero under websearch=1).

How a job gets through

Two stages. Cheap title filters first, then one page fetch per survivor.

Title filters (inside fetchJobs, no network cost):

  1. Switzerland guard — the card's location must say Switzerland. LinkedIn ignores geoId on unauthenticated requests and will happily return global results.
  2. NON_ENGLISH_RE — German gender markers (m/w/d, /in), umlauts, French H/F and accents.
  3. JUNIOR_TITLE_RE — junior, intern, trainee, werkstudent, entry-level.
  4. JAVA_TITLE_RE — Java-titled roles.
  5. BIO_DOMAIN_RE — bioinformatics, biostatistics, genomics, GMP/MSAT, bench-scientist titles. Filters on the role, not the employer: software and data jobs at Roche, Novartis or Swiss TPH stay in.
  6. SOFTWARE_TITLE_RE — requires a real tech/role term. Skipped in raw mode, which is what bun recheck uses.
  7. GERMAN_TITLE_RE — "(German-speaking)", "deutschsprachig".

Content check: isViableContent/isViableUrl (LinkedIn, one page fetch + 350 ms per job in index.ts) and isViableDescription (fetch-other.ts sources that already carry the description inline, no extra fetch) all run the same two checks — DE/FR indicator words dropped at ≥ 6, then requiresGerman() drops English postings demanding German above B1.

That last one is deliberately more than a keyword match, because the optional/required boundary is where every bug in it has been. Rafael is German B1, so "a plus" / "advantageous" / "B1" must survive and "fluent" / "C1" / "sehr gutes Deutsch" must not.

For each mention of German it takes the nearest qualifying marker, and ignores a marker when another language name sits between it and the mention — "Fluent in English; German and/or French advantageous" qualifies English, not German. But that rule has an exception: when the other language is coordinated with German the marker governs both, so "Excellent communication skills in both English and German" is correctly a hard requirement. Coordination means and/und/sowie/& — explicitly not and/or, since "Very good English and/or German" is satisfiable by English alone.

Two more cases it handles: German-language phrasing ("Sehr gutes Deutsch in Wort und Schrift", which for a long time matched nothing at all), and spec-sheet lines like "Languages: German & English" that have no adjective to bind to — there the Languages: label itself counts, though still only if no optional marker sits closer.

Profile match (profile.ts, digest only) scores the title against the CV's stack and seniority signals, needing 4+ points to notify. Hard rejects encode standing decisions: Microsoft stack, Java/C++/SAP, life-science domains, non-software functions, and the blocked companies Jobgether (an aggregator, not an employer) and ALLPS (postings require German despite passing the English filter).

Jobs are deduped globally by ID against results/jobs.csv on startup, so nothing is ever fetched or sent twice.

Output

results/jobs.csv — date_found, posted_at, search, title, company, link, source, location. search is the query or source that found the job; location is the posting's own location, which is what the digest groups on (a "Bern" sweep can surface a job actually based in Lausanne). Open it in any spreadsheet and delete rows as you apply or dismiss.

Configuring searches

config.ts holds four keyword families as separate OR-strings — KEYWORDS, PLATFORM_KEYWORDS, RESEARCH_KEYWORDS, FIELD_KEYWORDS. They're kept apart on purpose: LinkedIn ranks a focused query far better than one 50-term disjunction. sweep() generates one search per area per family (Research skips Bern; Platform and Field add a Switzerland-wide remote pass).

export const searches: SearchConfig[] = [
  {
    label: "Basel (city)",
    keywords: KEYWORDS,
    geoId: "103383283",
    workType: ["onsite", "hybrid"],
    timePosted: "month",
  },
];

SearchConfig also takes jobType, seniority, and location (use "City, Switzerland" when the geoId is unknown — LinkedIn resolves by name). A url: field overrides all filter params, so you can paste a LinkedIn search URL directly.

Known Swiss geoIds:

area geoId
Basel (city) 103383283
Basel Metropolitan Area 90009885
Basel-Country 107117530
Zürich (city) 102436504
Zürich Metropolitan Area 107814425
Zürich Area 90009888

One-off searches

Don't edit config.ts for a single search — write a throwaway in scripts/:

import { KEYWORDS } from "../config.ts";
import { fetchJobs } from "../fetch.ts";

const jobs = await fetchJobs({
  label: "Luzern", keywords: KEYWORDS,
  location: "Luzern, Switzerland",
  workType: ["onsite", "hybrid"], timePosted: "month",
});
for (const j of jobs) console.log(j.title, j.company, j.link);
bun run scripts/luzern-temp.ts

*-temp.ts scripts are disposable — delete them when the search is done.

Scheduling

Both automations are launchd agents, not cron — this machine has no cron at all.

launchctl list | grep -E 'linkme|scicore'                     # are they loaded?
launchctl unload ~/Library/LaunchAgents/com.rafapolo.linkme-daily.plist   # stop the digest
launchctl unload ~/Library/LaunchAgents/ch.polo.scicore-watch.plist       # stop the watcher

If the Mac is asleep at the scheduled time, launchd runs the job once it wakes rather than skipping the day.

Logs: results/daily.log for the digest, results/.scicore-watch/watch.log for the watcher. The watcher logs every run — no change, change ignored, or the diff it sent — so silence on your phone can always be told apart from a broken script. Test its alert path without waiting for the real page to change:

SCICORE_URL="file://$PWD/fixture.html" bash scripts/scicore-watch.sh

recheck

bun recheck re-runs every search with SOFTWARE_TITLE_RE bypassed and lists each job the filters would drop, plus a word-frequency table of those titles. Use it to spot false negatives; ignore the generic suggestions (engineer, development, senior) and look for real role terms worth adding to the regex.

S
Description
No description provided
Readme
12 MiB
Languages
TypeScript 91%
Shell 9%