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.
12 KiB
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—KEYWORDSstring (joined withOR) andsearches: 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) andFIELD_KEYWORDS(forward-deployed, customer/sales/integration, founding/product). Kept as separate OR-strings rather than folded intoKEYWORDS— LinkedIn ranks a focused query far better than one 50-term disjunction. Thesweep()helper generates one search per area per family; Research skips Bern, Platform and Field add a Switzerland-wide remote pass. RESEARCH_KEYWORDSdeliberately contains no bioinformatics/computational-biology terms — seeBIO_DOMAIN_RE.
- Also exports
fetch.ts— HTTP scraping, HTML parsing, and all filters. ExportsfetchJobs(config, { raw? })andisEnglishContent(id).fetch-other.ts— scrapes extra sources (datacareer.ch, stellen-basel.ch, itjobs.ch, job-room.ch, Basel international orgs). Imported byindex.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 asRole, City, Countryand 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, soisEnglishUrlwould reject everything;contentChecked: trueand 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. Therounded-3xlcard 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_REalone drops, soINTL_TECH_REwidens the net for these sources only. Boards this small can afford the noise; missing a rare opening can't.
- BIS — the career site is JS-rendered with no usable API, so we read the UNjobs mirror at
- itjobs.ch is swept Switzerland-wide (no
locationparam) — 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 fulldescription, so the DE/FR check runs in-process viaisEnglishDescriptionrather than costing a page fetch per job. Usejob_location.namefor location — it's the English form; the barelocationfield is German.
index.ts— orchestrator: loads existing IDs fromresults/jobs.csv, callsfetchJobs, callsisEnglishContentfor each new job, appends survivors to CSV.recheck.ts— callsfetchJobs(..., { raw: true })to bypassSOFTWARE_TITLE_RE, then lists every new job that would be filtered out and prints word-frequency suggestions.
Filter pipeline (applied in order)
- Switzerland location guard (card HTML) — extracts
job-search-card__locationfrom each card; drops jobs where location doesn't contain "Switzerland". Prevents LinkedIn from returning global results when geoId is ignored on unauthenticated requests. NON_ENGLISH_RE(title) — drops German gender markers, umlauts,/insuffix, French accents/markers. Runs insidefetchJobs.JUNIOR_TITLE_RE(title) — drops junior/intern/trainee/werkstudent. Runs insidefetchJobs.JAVA_TITLE_RE(title) — drops Java-titled roles. Runs insidefetchJobs.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 insidefetchJobs.SOFTWARE_TITLE_RE(title) — requires a relevant tech/role term. Skipped whenraw: true. Runs insidefetchJobs. 6.5.GERMAN_TITLE_RE(title) — drops "(German-speaking)" / "deutschsprachig" titles without a description fetch. Runs insidefetchJobs.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 inindex.tsonly (notrecheck.ts). Adds 350 ms delay per job.isEnglishContent/isEnglishUrlremain exported forrecheck.tsand 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:
// 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);
// Remote-only across all Switzerland
const config: SearchConfig = {
label: "Switzerland Remote", keywords: KEYWORDS, location: "Switzerland",
workType: ["remote"], timePosted: "month",
};
// 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 rolesJUNIOR_TITLE_RE— drop junior/internisEnglishContent(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"
- Write a temp script in
scripts/with the rightSearchConfig(city, workType, custom keywords). - Apply extra inline filters as needed: EMEA, .NET, description-level keyword check.
- Run
isEnglishContentper job to drop DE/FR content. - Rank survivors against
cv/polo.md, present as a table. - 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:
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"
- Run
bun recheck. - Identify false negatives — real software roles that got dropped.
- Ignore generic frequency suggestions (
engineer,development,senior). - Propose specific additions to
SOFTWARE_TITLE_REinfetch.tsor words toNON_ENGLISH_RE.