Broaden the search beyond engineer/developer titles and stop three
classes of bad result from ever reaching the CSV.
config.ts
- PLATFORM_KEYWORDS (architect, SRE, infra, security, MLOps),
RESEARCH_KEYWORDS (research/scientific software, HPC, data platform)
and FIELD_KEYWORDS (forward-deployed, customer/sales, founding).
Separate OR-strings, not folded into KEYWORDS: LinkedIn ranks a
focused query far better than one 50-term disjunction.
- sweep() generates one search per area per family. 7 searches -> 17.
fetch.ts
- BIO_DOMAIN_RE drops life-science roles (bioinformatics, biostats,
GMP/MSAT, genomics, pathology, drug discovery). Filters on the role,
never the employer: software jobs at Roche/Novartis/sciCORE stay.
- requiresGerman() drops English postings demanding German above B1.
Two rules, both from real false results: the nearest marker wins
("Infrastructure skills are a plus" sat 95 chars before "Good
knowledge of German"), and an intervening language name breaks the
binding ("Fluent in English; German and/or French advantageous").
- GERMAN_TITLE_RE catches "(German-speaking)" without a page fetch.
- isViableContent/isViableUrl run the DE/FR and German-requirement
checks on a single fetch; isEnglishContent/isEnglishUrl kept for
recheck.ts and temp scripts.
- SOFTWARE_TITLE_RE widened so the new families survive it.
index.ts
- both content-check paths use the combined check and report which
filter dropped a job.
172 lines
12 KiB
Markdown
172 lines
12 KiB
Markdown
# CLAUDE.md
|
|
|
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
|
|
## Commands
|
|
|
|
| command | what it does |
|
|
|---|---|
|
|
| `bun fetch` | run all searches in `config.ts`, append new jobs to `results/jobs.csv` |
|
|
| `bun recheck` | fetch in raw mode, list filtered-out jobs + word-frequency keyword suggestions |
|
|
|
|
Temp scripts: `bun run scripts/<name>-temp.ts` — never edit `config.ts` for one-off searches.
|
|
|
|
## Structure
|
|
|
|
```
|
|
config.ts # search keywords and areas
|
|
fetch.ts # scraper, HTML parser, all filters
|
|
fetch-other.ts # extra sources (datacareer.ch, stellen-basel.ch, itjobs.ch, job-room.ch, Basel intl orgs)
|
|
index.ts # orchestrator: runs searches, writes CSV
|
|
recheck.ts # raw-mode fetch, lists filtered-out jobs
|
|
scripts/ # one-off search scripts; import from ../config.ts and ../fetch.ts
|
|
cv/polo.md # Rafael's profile — use for ranking jobs
|
|
cv/ # CV files (html, pdf, odt)
|
|
results/jobs.csv # output — one row per job
|
|
jobs/ # notes on specific job leads
|
|
plans/ # LinkedIn post drafts
|
|
```
|
|
|
|
## Architecture
|
|
|
|
- **`config.ts`** — `KEYWORDS` string (joined with ` OR `) and `searches: SearchConfig[]`. Edit here to add/remove search areas or keywords.
|
|
- Also exports `PLATFORM_KEYWORDS` (architect/SRE/infra/security/MLOps), `RESEARCH_KEYWORDS` (research & scientific software, HPC, data platform) and `FIELD_KEYWORDS` (forward-deployed, customer/sales/integration, founding/product). Kept as separate OR-strings rather than folded into `KEYWORDS` — LinkedIn ranks a focused query far better than one 50-term disjunction. The `sweep()` helper generates one search per area per family; Research skips Bern, Platform and Field add a Switzerland-wide remote pass.
|
|
- `RESEARCH_KEYWORDS` deliberately contains no bioinformatics/computational-biology terms — see `BIO_DOMAIN_RE`.
|
|
- **`fetch.ts`** — HTTP scraping, HTML parsing, and all filters. Exports `fetchJobs(config, { raw? })` and `isEnglishContent(id)`.
|
|
- **`fetch-other.ts`** — scrapes extra sources (datacareer.ch, stellen-basel.ch, itjobs.ch, job-room.ch, Basel international orgs). Imported by `index.ts`.
|
|
- **datacareer.ch** currently returns **403** on every page — the source is dead and yields nothing. Needs new headers or a different entry point.
|
|
- **Basel international orgs** (`fetchBaselIntlOrgs`) sweeps three boards under one label, each only a handful of postings:
|
|
- **BIS** — the career site is JS-rendered with no usable API, so we read the UNjobs mirror at `unjobs.org/offices/bis_182320855689338880`. Titles arrive as `Role, City, Country` and get split back apart. BIS also cycles Senior Software / Data / Data & Analytics Engineer roles through this board.
|
|
- **Swiss TPH** — Umantis at `jobs.swisstph.ch/Jobs/All`. Site chrome is German even on English postings, so `isEnglishUrl` would reject everything; `contentChecked: true` and the title filters decide instead.
|
|
- **Basel Institute on Governance** — `baselgovernance.org/about/careers`. Each vacancy is a linked PDF, so there's no HTML description to language-check. The `rounded-3xl` card class is what separates vacancy tiles from the annual-report PDF link.
|
|
- These boards use UN-flavoured titles ("Information Management Officer", "ICT Specialist") that `SOFTWARE_TITLE_RE` alone drops, so `INTL_TECH_RE` widens the net **for these sources only**. Boards this small can afford the noise; missing a rare opening can't.
|
|
- **itjobs.ch** is swept Switzerland-wide (no `location` param) — the board is small, ~210 jobs over 7 pages, and locking it to Basel returned only ~9. It 404s one page past the end instead of serving an empty list. Each item embeds its full `description`, so the DE/FR check runs in-process via `isEnglishDescription` rather than costing a page fetch per job. Use `job_location.name` for location — it's the English form; the bare `location` field is German.
|
|
- **`index.ts`** — orchestrator: loads existing IDs from `results/jobs.csv`, calls `fetchJobs`, calls `isEnglishContent` for each new job, appends survivors to CSV.
|
|
- **`recheck.ts`** — calls `fetchJobs(..., { raw: true })` to bypass `SOFTWARE_TITLE_RE`, then lists every new job that *would* be filtered out and prints word-frequency suggestions.
|
|
|
|
### Filter pipeline (applied in order)
|
|
|
|
1. **Switzerland location guard** (card HTML) — extracts `job-search-card__location` from each card; drops jobs where location doesn't contain "Switzerland". Prevents LinkedIn from returning global results when geoId is ignored on unauthenticated requests.
|
|
2. **`NON_ENGLISH_RE`** (title) — drops German gender markers, umlauts, `/in` suffix, French accents/markers. Runs inside `fetchJobs`.
|
|
3. **`JUNIOR_TITLE_RE`** (title) — drops junior/intern/trainee/werkstudent. Runs inside `fetchJobs`.
|
|
4. **`JAVA_TITLE_RE`** (title) — drops Java-titled roles. Runs inside `fetchJobs`.
|
|
5. **`BIO_DOMAIN_RE`** (title) — drops roles whose *domain* is biology/chemistry/the lab bench (bioinformatics, biostatistics, GMP/MSAT, genomics, pathology, drug discovery, bench Scientist titles). Filters on the **role, not the employer** — software/platform/data jobs at Roche, Novartis, Swiss TPH and sciCORE stay in scope. Runs inside `fetchJobs`.
|
|
6. **`SOFTWARE_TITLE_RE`** (title) — requires a relevant tech/role term. Skipped when `raw: true`. Runs inside `fetchJobs`.
|
|
6.5. **`GERMAN_TITLE_RE`** (title) — drops "(German-speaking)" / "deutschsprachig" titles without a description fetch. Runs inside `fetchJobs`.
|
|
7. **`isViableContent`** / **`isViableUrl`** (description page) — one fetch, two checks: (a) counts DE/FR indicator words, drops if ≥ 6; (b) `requiresGerman()` drops English postings that demand German above B1. Runs in `index.ts` only (not `recheck.ts`). Adds 350 ms delay per job. `isEnglishContent` / `isEnglishUrl` remain exported for `recheck.ts` and temp scripts.
|
|
|
|
**`requiresGerman(text)`** is deliberately more than a keyword match — Rafael is German B1 (TELC), so B1/"basic"/"a plus" is fine and B2+ is not. For each mention of German it takes the *nearest* qualifying marker, and a marker is ignored when another language name sits between it and the mention. Both rules come from real false results:
|
|
- *"Infrastructure skills are **a plus** … Good knowledge of **German**"* (Helvetia) — presence-based matching let an unrelated "a plus" 95 chars away cancel a real requirement.
|
|
- *"Fluent in **English**; **German** and/or French advantageous"* (albedis) — "Fluent" is nearer to "German" than "advantageous" is, but qualifies English.
|
|
|
|
Deduplication: job IDs are extracted from `results/jobs.csv` on startup; any ID already seen is skipped before content-checking.
|
|
|
|
### SearchConfig
|
|
|
|
`url` overrides all filter params. Otherwise build from: `keywords`, `geoId`/`location`, `workType`, `jobType`, `timePosted`, `seniority`.
|
|
|
|
Known Swiss geoIds:
|
|
- Basel city: `103383283`
|
|
- Basel Metropolitan Area: `90009885`
|
|
- Basel-Country: `107117530`
|
|
- Zürich city: `102436504`
|
|
- Zürich Metropolitan Area: `107814425`
|
|
- Zürich Area: `90009888`
|
|
|
|
Use `location: "City, Switzerland"` (no `geoId`) when the geoId is unknown — LinkedIn resolves by name.
|
|
|
|
**Auth warning:** Do NOT send the `li_at` cookie in job search requests (`/jobs/search/`). With the cookie, LinkedIn serves the React SPA instead of server-rendered HTML, breaking the card parser entirely. The cookie is only useful for individual profile pages.
|
|
|
|
## Searching any city / mode temporarily
|
|
|
|
Write a throwaway script in `scripts/` (e.g. `scripts/bern-temp.ts`) — **never touch `config.ts`** unless the user explicitly asks to save the change. Delete temp scripts after the session unless the user asks to keep them.
|
|
|
|
Common patterns:
|
|
|
|
```ts
|
|
// City search (onsite + hybrid)
|
|
import { KEYWORDS } from "../config.ts";
|
|
import { fetchJobs } from "../fetch.ts";
|
|
import type { SearchConfig } from "../config.ts";
|
|
|
|
const config: SearchConfig = {
|
|
label: "Bern", keywords: KEYWORDS, location: "Bern, Switzerland",
|
|
workType: ["onsite", "hybrid"], timePosted: "month",
|
|
};
|
|
const jobs = await fetchJobs(config);
|
|
for (const j of jobs) console.log(j.title, j.company, j.link);
|
|
```
|
|
|
|
```ts
|
|
// Remote-only across all Switzerland
|
|
const config: SearchConfig = {
|
|
label: "Switzerland Remote", keywords: KEYWORDS, location: "Switzerland",
|
|
workType: ["remote"], timePosted: "month",
|
|
};
|
|
```
|
|
|
|
```ts
|
|
// Description-level keyword check (e.g. Ruby/Rails)
|
|
const RUBY_RE = /\bruby\b|\brails\b/i;
|
|
// fetch each job page, test RUBY_RE against the description text
|
|
```
|
|
|
|
Extra filters for temp scripts (import from `../fetch.ts`):
|
|
- `JAVA_TITLE_RE` — drop Java roles
|
|
- `JUNIOR_TITLE_RE` — drop junior/intern
|
|
- `isEnglishContent(id)` — drop DE/FR description content
|
|
- Add your own inline RE for EMEA, .NET, etc.
|
|
|
|
## Ranking jobs against the CV
|
|
|
|
Read `cv/polo.md` for the full profile. Key signals:
|
|
|
|
- **Seniority**: Senior / Staff / Principal / Lead / Architect — weight titles accordingly
|
|
- **Core stack**: Python · TypeScript · Ruby on Rails · React · Node.js
|
|
- **Domain strengths**: platform engineering · data pipelines · AI/LLM integrations · DevOps (Docker, Kubernetes, CI/CD, AWS)
|
|
- **Background edges**: fintech/CBDC (Moedax), civic tech (Tactical Tech / GFOSS), independent consulting (ExtraPolo, 40+ projects)
|
|
- **Languages**: English (full) · German B1 TELC certified · Spanish (full) — **drop** roles requiring German above B1; `requiresGerman()` does this automatically at fetch time
|
|
- **Hard no: biological domains** — drop bioinformatics, computational biology, biostatistics, lab/bench Scientist, GMP/MSAT, clinical/assay and drug-discovery-science roles; he has no life-science background. Software roles *at* pharma companies are still strong targets
|
|
- **Hard no: Microsoft stack** — drop .NET/C#, Azure-first, Windows/SCCM/Intune, SharePoint, Dynamics, Power Platform roles entirely; don't present them even as weak fits
|
|
- **Location**: based in Basel, open to Basel / Bern / Zürich; EU citizen, no permit needed
|
|
|
|
## Output format
|
|
|
|
**Always** present jobs as a markdown table — never as a bullet list or prose: `#`, `Title`, `Company`, `Canton`, `Why it fits`, `Link`.
|
|
|
|
`Canton` is mandatory on every table, including single-city searches and the list of dropped/rejected jobs (there `Why dropped` replaces `Why it fits`). Derive it from the actual location on the posting, not from the search label — recruiters and multi-site companies often post under a different area than the job sits in. Use the two-letter code: BS, BL, ZH, BE, ZG, LU, VD, GE, TI, AG, SG, TG. For remote roles write `Remote` (add scope if known, e.g. `Remote (EU)`).
|
|
|
|
Links use just the job ID as anchor text: `[4192954199](https://www.linkedin.com/jobs/view/4192954199/)`
|
|
|
|
## Workflow: "find jobs in X city / remote / custom search"
|
|
|
|
1. Write a temp script in `scripts/` with the right `SearchConfig` (city, workType, custom keywords).
|
|
2. Apply extra inline filters as needed: EMEA, .NET, description-level keyword check.
|
|
3. Run `isEnglishContent` per job to drop DE/FR content.
|
|
4. Rank survivors against `cv/polo.md`, present as a table.
|
|
5. Delete temp scripts after the session unless asked to keep them.
|
|
|
|
## LinkedIn session token
|
|
|
|
`.env` contains `LI_AT` — the LinkedIn session cookie (`li_at` value). Use it to fetch authenticated LinkedIn pages via curl:
|
|
|
|
```bash
|
|
curl -s "https://www.linkedin.com/in/<slug>" \
|
|
-H "Cookie: li_at=$(grep LI_AT .env | cut -d= -f2-)" \
|
|
-H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" \
|
|
--compressed -o /tmp/profile.html
|
|
```
|
|
|
|
**What works:** Full HTML pages for profiles that don't 999-block (varies by profile). Extract data with Python regex on the HTML.
|
|
|
|
**What doesn't:** LinkedIn Voyager API returns 401/403 (CSRF issues). `ch.linkedin.com` slugs sometimes work when `www.linkedin.com` returns HTTP 999. Public profiles without login redirect to a 1530-byte JS stub — no useful data.
|
|
|
|
**Tip:** Profile data is embedded as escaped JSON in the HTML. Use `html.unescape()` + regex for `"headline"`, `"locationName"`, `defaultLocalizedName` fields.
|
|
|
|
## Workflow: "check if new keywords should be added"
|
|
|
|
1. Run `bun recheck`.
|
|
2. Identify false negatives — real software roles that got dropped.
|
|
3. Ignore generic frequency suggestions (`engineer`, `development`, `senior`).
|
|
4. Propose specific additions to `SOFTWARE_TITLE_RE` in `fetch.ts` or words to `NON_ENGLISH_RE`.
|