2026-10-06 15:17:17 +02:00
2026-10-06 15:17:17 +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 throughout the day.

One thing runs on its own once installed:

automation when what it does
daily digest 09:00 / 12:00 / 15:00 / 17:00 / 20:00 fetch every search + source, push new profile-matching jobs to Telegram

sciCORE used to have its own separate 08:47 watcher; it's now just another source swept inside bun fetch/bun daily (see Sources below).

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 ten 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); scicore-watch.sh kept for history, retired
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
scicore.unibas.ch Basel sciCORE's open-positions announcement list; used to be its own separate 08:47 watcher, now just another source
swisscom.com Switzerland Swisscom's own Workday career site, pulled via its public CXS JSON API; small board and mostly German-language, so few survive the filters
more-jobs.ch Switzerland Server-rendered aggregator; keyword sweep of /recherche?q=, then one detail fetch per title-filtered candidate (real date + DE/FR check). Reposts of jobs other sources already carry are dropped by normalized title+company
startup.jobs Switzerland Behind Cloudflare: a headless Chrome reads the page's short-lived Algolia search key, then the Algolia index is swept over a Swiss bounding box (split recursively past Algolia's 500-hit cap). Detail pages are fetched in-browser for the DE/FR check; reposts other sources carry are dropped by normalized title+company

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

The digest runs as a launchd agent, not cron — this machine has no cron at all.

launchctl list | grep linkme                                             # is it loaded?
launchctl unload ~/Library/LaunchAgents/com.rafapolo.linkme-daily.plist   # stop it

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.

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%