Files

187 lines
12 KiB
Markdown
Raw Permalink 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 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
```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 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:
```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
The digest runs as a launchd agent, not cron — this machine has no cron at all.
```bash
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.