# 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/-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) 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. - **`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). Imported by `index.ts`. - **`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. **`SOFTWARE_TITLE_RE`** (title) — requires a relevant tech/role term. Skipped when `raw: true`. Runs inside `fetchJobs`. 6. **`isEnglishContent`** (description page) — fetches the job page, counts DE/FR indicator words, drops if ≥ 6 matches. Runs in `index.ts` only (not `recheck.ts`). Adds 350 ms delay per job. 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) — flag roles requiring C1+ German - **Location**: based in Basel, open to Basel / Bern / Zürich; EU citizen, no permit needed ## Output format Present jobs as a markdown table: `#`, `Title`, `Company`, `Why it fits`, `Link`. 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/" \ -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`.