Files
linkme/README.md
T
rodado fccecb2254 docs: document all eight sources in the readme
The readme listed the extra sources inline in the structure block and
still described datacareer.ch as "403, currently dead". Replace that with
a Sources table covering LinkedIn plus the eight boards in fetch-other.ts,
with scope and the parsing quirk that matters for each.

Write up the two that were dead: datacareer.ch's inverted User-Agent rule,
and basel-jobs.ch's DataDome cookie — what gets through, what doesn't, how
to mint, and why a launchd run degrades instead of failing.

Also correct two things that had drifted: requiresGerman now covers
coordination, German-language phrasing and spec-sheet language lines, and
the CSV gained a location column that the Output section never mentioned.
2026-09-01 12:39:27 +02:00

187 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```bash
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
```bash
# 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:
```bash
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).
```ts
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/`:
```ts
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);
```
```bash
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.
```bash
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:
```bash
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.