feat: add startup.jobs as a switzerland-wide job source

This commit is contained in:
2026-10-06 15:17:17 +02:00
parent ab659008e6
commit e70ae1e932
4 changed files with 245 additions and 8 deletions
+5 -2
View File
@@ -21,7 +21,7 @@ Temp scripts: `bun run scripts/<name>-temp.ts` — never edit `config.ts` for on
``` ```
config.ts # search keywords and areas config.ts # search keywords and areas
fetch.ts # scraper, HTML parser, all filters 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, jobbasel.ch, englishjobsearch.ch, basel-jobs.ch, scicore.unibas.ch, swisscom.com, novartis.com, more-jobs.ch) fetch-other.ts # extra sources (datacareer.ch, stellen-basel.ch, itjobs.ch, job-room.ch, Basel intl orgs, jobbasel.ch, englishjobsearch.ch, basel-jobs.ch, scicore.unibas.ch, swisscom.com, novartis.com, more-jobs.ch, startup.jobs)
index.ts # orchestrator: runs searches, writes CSV index.ts # orchestrator: runs searches, writes CSV
recheck.ts # raw-mode fetch, lists filtered-out jobs recheck.ts # raw-mode fetch, lists filtered-out jobs
daily.ts # digest: fetch → match against profile.ts → send to Telegram daily.ts # digest: fetch → match against profile.ts → send to Telegram
@@ -43,7 +43,7 @@ plans/ # LinkedIn post drafts
- 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. - 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`. - `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.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, jobbasel.ch, englishjobsearch.ch, basel-jobs.ch, scicore.unibas.ch, swisscom.com, novartis.com, more-jobs.ch). Imported by `index.ts`. - **`fetch-other.ts`** — scrapes extra sources (datacareer.ch, stellen-basel.ch, itjobs.ch, job-room.ch, Basel international orgs, jobbasel.ch, englishjobsearch.ch, basel-jobs.ch, scicore.unibas.ch, swisscom.com, novartis.com, more-jobs.ch, startup.jobs). Imported by `index.ts`.
- **novartis.com** (`fetchNovartisBasel`, added 2026-09-21) — Rafael has an internal contact at Novartis Basel who can refer him, so this source exists purely to catch a genuine software/engineering opening there, not to widen the CSV generally. Its career-search page (`novartis.com/careers/career-search?country[]=LOC_CH`) is a server-rendered Drupal view — no headless browser needed — but only at the **locale-less English path** (`/careers/career-search`); the `ch-de` German path's job-detail links 302 straight back to the search page for both locales when hit without a session, so detail pages only resolve through the English path. Pagination is 0-indexed (`page=0` implicit, `page=1` is the second page of 10) and the page's own "Showing N results" line gives the exact total, so the page-loop bound is computed rather than probed — the listing has also been observed to **repeat rows across pages within one sweep** (measured 2026-09-21: several titles appeared twice across 5 pages), so results are deduped by `req-<id>` before filtering, not trusted from the pager. - **novartis.com** (`fetchNovartisBasel`, added 2026-09-21) — Rafael has an internal contact at Novartis Basel who can refer him, so this source exists purely to catch a genuine software/engineering opening there, not to widen the CSV generally. Its career-search page (`novartis.com/careers/career-search?country[]=LOC_CH`) is a server-rendered Drupal view — no headless browser needed — but only at the **locale-less English path** (`/careers/career-search`); the `ch-de` German path's job-detail links 302 straight back to the search page for both locales when hit without a session, so detail pages only resolve through the English path. Pagination is 0-indexed (`page=0` implicit, `page=1` is the second page of 10) and the page's own "Showing N results" line gives the exact total, so the page-loop bound is computed rather than probed — the listing has also been observed to **repeat rows across pages within one sweep** (measured 2026-09-21: several titles appeared twice across 5 pages), so results are deduped by `req-<id>` before filtering, not trusted from the pager.
Novartis Basel's titling is almost entirely non-technical "Director, `<business function>`" roles (Portfolio Analytics, Therapeutic Area Strategy, Policy Communications, ...) plus device/hardware "Engineering" roles (autoinjector/drug-delivery hardware, not software) — the generic `SOFTWARE_TITLE_RE` used by every other source in this file matched **13 of 47** CH listings on title alone in an initial run, and every single one was a false positive (no engineering-team role among them). `NOVARTIS_TITLE_RE` replaces it here: it requires an explicit software/IT engineering job-family term (`software engineer`, `platform engineer`, `data engineer`, `devops`, `sre`, `solutions architect`, ...), not just a bare seniority or business-function word. `BIO_DOMAIN_RE` and job-room.ch's `PHARMA_ROLE_RE` still run on top to catch domain-science titles the stricter regex wouldn't otherwise need to worry about. Narrowed to Basel/Muttenz specifically (`NOVARTIS_BASEL_RE`) — the CH listing also includes Stein (Aargau), a different commute the contact couldn't refer into. As of 2026-09-21 this source keeps **zero** listings — expected; it's watching for the rare opening, not sweeping volume. Each detail page carries the full description inline (`job_details_content_center`…`job_details_content_bottom`), so the DE/FR + `requiresGerman` check runs in-process per candidate rather than costing `index.ts` a page fetch. Novartis Basel's titling is almost entirely non-technical "Director, `<business function>`" roles (Portfolio Analytics, Therapeutic Area Strategy, Policy Communications, ...) plus device/hardware "Engineering" roles (autoinjector/drug-delivery hardware, not software) — the generic `SOFTWARE_TITLE_RE` used by every other source in this file matched **13 of 47** CH listings on title alone in an initial run, and every single one was a false positive (no engineering-team role among them). `NOVARTIS_TITLE_RE` replaces it here: it requires an explicit software/IT engineering job-family term (`software engineer`, `platform engineer`, `data engineer`, `devops`, `sre`, `solutions architect`, ...), not just a bare seniority or business-function word. `BIO_DOMAIN_RE` and job-room.ch's `PHARMA_ROLE_RE` still run on top to catch domain-science titles the stricter regex wouldn't otherwise need to worry about. Narrowed to Basel/Muttenz specifically (`NOVARTIS_BASEL_RE`) — the CH listing also includes Stein (Aargau), a different commute the contact couldn't refer into. As of 2026-09-21 this source keeps **zero** listings — expected; it's watching for the rare opening, not sweeping volume. Each detail page carries the full description inline (`job_details_content_center`…`job_details_content_bottom`), so the DE/FR + `requiresGerman` check runs in-process per candidate rather than costing `index.ts` a page fetch.
@@ -90,6 +90,9 @@ plans/ # LinkedIn post drafts
Swisscom mostly hires in German — of 47 postings, only ~16 clear the English/software title filters, and nearly all of those turned out to be written in German too; only genuinely-English, German-optional roles survive. Adding this source surfaced a real bug in `requiresGerman()`: the stock line *"Fluency in English is required; proficiency in German is considered an advantage"* had the noun "proficiency" (a required-marker word) sitting right next to "German", closer than the real optional-marker "advantage" — nearest() picked the wrong one and wrongly flagged the posting as requiring German. Fixed by adding a local-predicate override (`GERMAN_LOCAL_PREDICATE_RE`): when a copula ("is"/"are", optionally "considered"/"deemed") directly follows the German mention, that immediate predicate decides the verdict outright instead of competing on raw character distance with markers elsewhere in the window — "German is considered an advantage" resolves as optional regardless of what else is nearby. It only fires when a copula immediately follows the mention, so *"Work proficiency in German, English, and French"* (no copula after "German", a genuine hard requirement — Senior Operation Architect (FR/GER/ENG)) still falls through to the general nearest-marker logic and is correctly caught as required. Re-check both cases (and the pre-existing Helvetia/albedis/Karakun/J. Safra Sarasin/BBF/spec-sheet cases in the paragraph above) before touching `requiresGerman` again. Swisscom mostly hires in German — of 47 postings, only ~16 clear the English/software title filters, and nearly all of those turned out to be written in German too; only genuinely-English, German-optional roles survive. Adding this source surfaced a real bug in `requiresGerman()`: the stock line *"Fluency in English is required; proficiency in German is considered an advantage"* had the noun "proficiency" (a required-marker word) sitting right next to "German", closer than the real optional-marker "advantage" — nearest() picked the wrong one and wrongly flagged the posting as requiring German. Fixed by adding a local-predicate override (`GERMAN_LOCAL_PREDICATE_RE`): when a copula ("is"/"are", optionally "considered"/"deemed") directly follows the German mention, that immediate predicate decides the verdict outright instead of competing on raw character distance with markers elsewhere in the window — "German is considered an advantage" resolves as optional regardless of what else is nearby. It only fires when a copula immediately follows the mention, so *"Work proficiency in German, English, and French"* (no copula after "German", a genuine hard requirement — Senior Operation Architect (FR/GER/ENG)) still falls through to the general nearest-marker logic and is correctly caught as required. Re-check both cases (and the pre-existing Helvetia/albedis/Karakun/J. Safra Sarasin/BBF/spec-sheet cases in the paragraph above) before touching `requiresGerman` again.
- **more-jobs.ch** (`fetchMoreJobs`, added 2026-10-02) — server-rendered PHP aggregator. `/recherche?q=<term>&page=n` is fast (~0.5 s) with ~20 cards/page, but `/emplois` and out-of-range pages are slow (20 s+) and **clamp to the last page instead of going empty**, so a term's sweep ends on "no new ids", not an empty list. Swept with `MOREJOBS_TERMS` (≤5 pages each), then each title-filtered candidate gets one detail fetch (4 in parallel, 20 s timeout) for the real `Publication date` (dd.mm.yyyy) and the in-process DE/FR + `requiresGerman` check (`contentChecked: true`). The board **reposts one opening under several IDs** (measured: Accelleron, Swissquote, Bayer ×3), and it also mirrors jobs that LinkedIn/jobs.ch carry, so its `extraSources` entry sets `dedupeByTitle`: `index.ts` skips any job whose `langKey(title, company)` is already in the CSV (or claimed earlier in the same run). Other sources are deliberately not deduped this way. The board also lists **foreign postings** ("Eastern Europe, Poland", "95050 Santa Clara", "London") and its JSON-LD stamps `addressCountry: "CH"` on every one, so the card's location text is the only signal: `MOREJOBS_FOREIGN_RE` drops locations naming another country/foreign city or carrying a 5-digit postcode before the detail fetch (19 such rows had leaked into the CSV by 2026-10-03, several reaching the Telegram digest). - **more-jobs.ch** (`fetchMoreJobs`, added 2026-10-02) — server-rendered PHP aggregator. `/recherche?q=<term>&page=n` is fast (~0.5 s) with ~20 cards/page, but `/emplois` and out-of-range pages are slow (20 s+) and **clamp to the last page instead of going empty**, so a term's sweep ends on "no new ids", not an empty list. Swept with `MOREJOBS_TERMS` (≤5 pages each), then each title-filtered candidate gets one detail fetch (4 in parallel, 20 s timeout) for the real `Publication date` (dd.mm.yyyy) and the in-process DE/FR + `requiresGerman` check (`contentChecked: true`). The board **reposts one opening under several IDs** (measured: Accelleron, Swissquote, Bayer ×3), and it also mirrors jobs that LinkedIn/jobs.ch carry, so its `extraSources` entry sets `dedupeByTitle`: `index.ts` skips any job whose `langKey(title, company)` is already in the CSV (or claimed earlier in the same run). Other sources are deliberately not deduped this way. The board also lists **foreign postings** ("Eastern Europe, Poland", "95050 Santa Clara", "London") and its JSON-LD stamps `addressCountry: "CH"` on every one, so the card's location text is the only signal: `MOREJOBS_FOREIGN_RE` drops locations naming another country/foreign city or carrying a 5-digit postcode before the detail fetch (19 such rows had leaked into the CSV by 2026-10-03, several reaching the Telegram digest).
- **startup.jobs** (`fetchStartupJobs`, added 2026-10-06) — global startup board, swept Switzerland-wide. **Every path is behind a Cloudflare managed challenge** (403 on plain HTTP for any UA, feeds and `/api` included), and unlike basel-jobs.ch's DataDome cookie, `cf_clearance` **does not replay** over Bun's fetch — measured 403 with the exact cookie + UA; it's bound to Chrome's TLS fingerprint. What works: Chrome `--headless=new` with a throwaway profile clears the challenge **only if the UA has no "HeadlessChrome" token** (the version is read from `Chrome --version` so it doesn't drift). So this source launches a headless browser on every run — unlike basel-jobs.ch's minting, that works fine under launchd. `LINKME_NO_BROWSER=1` disables it; any failure logs `unavailable` and returns `[]`.
Listings come from **Algolia** (app `4CQMTMMK73`, index `Post_production`), not the HTML: the page embeds a *secured* search key in `meta[name="current-algolia-api-key-search"]`, and algolia.net is not behind Cloudflare, so the queries run over plain fetch. The key's `validUntil` is **~3h after page load** — read fresh every run, never cached. The key rejects every country filter (`country_code`/`country_id` → 0 hits), so the sweep is a geo bounding box around Switzerland with `country_code === "CH"` checked locally (the box also catches DE/FR/AT/LI/IT), limited to the last 60 days. Algolia caps a query at **500 retrievable hits** (100 × 5 pages) — a box over 500 is split into quadrants recursively (all ~1400 CH-box postings vs 556 in the 60-day window, measured 2026-10-06). `tagFilters` on `_tags` (Developer/Engineer) was tried and dropped: it covered only 365 of them. Records carry no description: each title-filtered candidate's detail page is fetched *inside* the browser (`page.evaluate(fetch)`, same TLS) and its JSON-LD `JobPosting.description` goes through `isViableDescription` (`contentChecked: true`). Links already in `results/jobs.csv` are skipped before that fetch. It aggregates company ATS feeds (Wise, Stripe, Avaloq, Nebius…) that LinkedIn also carries, so its `extraSources` entry sets `dedupeByTitle` like more-jobs.ch. The user's original URL was `?q=Developer&w=hybrid` around Basel; the source deliberately keeps on-site/remote too and uses `SOFTWARE_TITLE_RE` instead of the `Developer` query, matching every other source.
- **`index.ts`** — orchestrator: loads existing IDs from `results/jobs.csv`, calls `fetchJobs`, calls `isEnglishContent` for each new job, appends survivors to CSV. Writes both `search` (the query/source that found the job) and `location` (the posting's own location, as parsed off its card by `fetch.ts`/`fetch-other.ts` — no extra page fetch) to the CSV. Migrates old CSVs on startup: adds `source` if missing, then `location` (backfilled `""` for rows written before the column existed). - **`index.ts`** — orchestrator: loads existing IDs from `results/jobs.csv`, calls `fetchJobs`, calls `isEnglishContent` for each new job, appends survivors to CSV. Writes both `search` (the query/source that found the job) and `location` (the posting's own location, as parsed off its card by `fetch.ts`/`fetch-other.ts` — no extra page fetch) to the CSV. Migrates old CSVs on startup: adds `source` if missing, then `location` (backfilled `""` for rows written before the column existed).
- Every LinkedIn search and every `fetch-other.ts` source hits a different host, so they run **concurrently** (bounded: 3 at a time within each group, both groups started together) instead of one after another — cut a full `bun fetch` from ~16-20 min to well under 2 min. `existingLinks.add()` happens synchronously right after the `.has()` check and before any `await`, which is what makes concurrent workers safe against double-claiming the same link. Concurrency surfaced a real gap: LinkedIn started returning occasional 429s under parallel load, which `fetchJobs` used to treat as end-of-results (silently truncating that search) — `fetchJobs` now retries a 429 up to 3× with backoff (1s/2s/4s) before giving up. - Every LinkedIn search and every `fetch-other.ts` source hits a different host, so they run **concurrently** (bounded: 3 at a time within each group, both groups started together) instead of one after another — cut a full `bun fetch` from ~16-20 min to well under 2 min. `existingLinks.add()` happens synchronously right after the `.has()` check and before any `await`, which is what makes concurrent workers safe against double-claiming the same link. Concurrency surfaced a real gap: LinkedIn started returning occasional 429s under parallel load, which `fetchJobs` used to treat as end-of-results (silently truncating that search) — `fetchJobs` now retries a 429 up to 3× with backoff (1s/2s/4s) before giving up.
- **`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. - **`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.
+1
View File
@@ -71,6 +71,7 @@ LinkedIn is swept by `config.ts`'s search families. Everything else lives in `fe
| **scicore.unibas.ch** | Basel | sciCORE's open-positions announcement list; used to be its own separate 08:47 watcher, now just another source | | **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 | | **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 | | **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: Two of these were dead until recently and are worth knowing about:
+236 -5
View File
@@ -1,5 +1,7 @@
import { NON_ENGLISH_RE, JUNIOR_TITLE_RE, SOFTWARE_TITLE_RE, BIO_DOMAIN_RE, requiresGerman, hasNonEnglishUmlaut } from "./fetch.ts"; import { NON_ENGLISH_RE, JUNIOR_TITLE_RE, SOFTWARE_TITLE_RE, BIO_DOMAIN_RE, requiresGerman, hasNonEnglishUmlaut } from "./fetch.ts";
import type { Job } from "./fetch.ts"; import type { Job } from "./fetch.ts";
import { tmpdir } from "os";
import { rm } from "fs/promises";
const HEADERS = { const HEADERS = {
"User-Agent": "User-Agent":
@@ -332,7 +334,7 @@ const JOBROOM_API = "https://www.job-room.ch/jobadservice/api/jobAdvertisements/
const JOBROOM_CANTONS = ["BS", "BL", "BE", "ZH"]; const JOBROOM_CANTONS = ["BS", "BL", "BE", "ZH"];
const JOBROOM_KEYWORDS = [ const JOBROOM_KEYWORDS = [
"software", "engineer", "developer", "devops", "data", "python", "software", "engineer", "developer", "devops", "data", "python",
"cloud", "platform", "typescript", "backend", "full stack", "ai", "cloud", "platform", "typescript", "backend", "full stack", "ai", "agentic",
]; ];
// job-room carries every industry; pharma/lab titles slip past SOFTWARE_TITLE_RE // job-room carries every industry; pharma/lab titles slip past SOFTWARE_TITLE_RE
@@ -691,7 +693,7 @@ export async function fetchSciCore(): Promise<Job[]> {
// costing a page fetch per job in index.ts. // costing a page fetch per job in index.ts.
const JOBBASEL_KEYWORDS = [ const JOBBASEL_KEYWORDS = [
"software", "engineer", "developer", "devops", "data", "python", "software", "engineer", "developer", "devops", "data", "python",
"cloud", "platform", "typescript", "backend", "full stack", "ai", "cloud", "platform", "typescript", "backend", "full stack", "ai", "agentic",
]; ];
interface JobBaselItem { interface JobBaselItem {
@@ -872,7 +874,7 @@ export async function fetchEnglishJobSearch(): Promise<Job[]> {
// empty list. Cards link to /emploi/<slug>-<id>.html; the listing carries no // empty list. Cards link to /emploi/<slug>-<id>.html; the listing carries no
// description, so each title-filtered candidate costs one detail fetch, which // description, so each title-filtered candidate costs one detail fetch, which
// also gives the real publication date (dd.mm.yyyy) and runs the DE/FR check. // also gives the real publication date (dd.mm.yyyy) and runs the DE/FR check.
const MOREJOBS_TERMS = ["software", "engineer", "developer", "devops", "platform", "architect", "data", "python"]; const MOREJOBS_TERMS = ["software", "engineer", "developer", "devops", "platform", "architect", "data", "python", "agentic"];
const MOREJOBS_MAX_PAGES = 5; const MOREJOBS_MAX_PAGES = 5;
// The board is Swiss but lists foreign postings too ("Eastern Europe, Poland", // The board is Swiss but lists foreign postings too ("Eastern Europe, Poland",
// "95050 Santa Clara", "London"), and its JSON-LD stamps addressCountry "CH" on // "95050 Santa Clara", "London"), and its JSON-LD stamps addressCountry "CH" on
@@ -1228,7 +1230,7 @@ async function baselJobsFetch(
const BASELJOBS_TERMS = [ const BASELJOBS_TERMS = [
// role / domain words — these carry the `websearch=1` pass // role / domain words — these carry the `websearch=1` pass
"software", "engineer", "developer", "entwickler", "informatik", "data", "software", "engineer", "developer", "entwickler", "informatik", "data",
"cloud", "devops", "platform", "architect", "security", "ai", "cloud", "devops", "platform", "architect", "security", "ai", "agentic",
// stack words — only the bare search matches these // stack words — only the bare search matches these
"python", "javascript", "typescript", "react", "node", "python", "javascript", "typescript", "react", "node",
"fullstack", "full stack", "backend", "frontend", "kubernetes", "linux", "fullstack", "full stack", "backend", "frontend", "kubernetes", "linux",
@@ -1334,7 +1336,7 @@ function parseBaselJobsCards(
// term, not just a seniority or business-function word BIO_DOMAIN_RE and // term, not just a seniority or business-function word BIO_DOMAIN_RE and
// PHARMA_ROLE_RE happen to miss. // PHARMA_ROLE_RE happen to miss.
const NOVARTIS_TITLE_RE = const NOVARTIS_TITLE_RE =
/\b(software\s+(engineer|developer|architect)|solutions?\s+architect|platform\s+engineer|cloud\s+engineer|devops(?:\s+engineer)?|data\s+engineer|site\s+reliability\s+engineer|\bsre\b|backend\s+(?:engineer|developer)|frontend\s+(?:engineer|developer)|full.?stack\s+(?:developer|engineer)|machine\s+learning\s+engineer|\bmlops\b|infrastructure\s+engineer|integration\s+engineer|automation\s+engineer|it\s+engineer|systems?\s+engineer|application[s]?\s+engineer|security\s+engineer|ai\s+engineer|engineering\s+manager|technical\s+lead|tech\s+lead|principal\s+(?:software\s+)?engineer|staff\s+engineer|product\s+engineer|programmer|it\s+architect|enterprise\s+architect|data\s+scientist|data\s+platform|digital\s+product\s+(?:engineer|manager)|it\s+project\s+manager|it\s+manager)\b/i; /\b(software\s+(engineer|developer|architect)|solutions?\s+architect|platform\s+engineer|cloud\s+engineer|devops(?:\s+engineer)?|data\s+engineer|site\s+reliability\s+engineer|\bsre\b|backend\s+(?:engineer|developer)|frontend\s+(?:engineer|developer)|full.?stack\s+(?:developer|engineer)|machine\s+learning\s+engineer|\bmlops\b|infrastructure\s+engineer|integration\s+engineer|automation\s+engineer|it\s+engineer|systems?\s+engineer|application[s]?\s+engineer|security\s+engineer|ai\s+engineer|agentic|engineering\s+manager|technical\s+lead|tech\s+lead|principal\s+(?:software\s+)?engineer|staff\s+engineer|product\s+engineer|programmer|it\s+architect|enterprise\s+architect|data\s+scientist|data\s+platform|forward\s+deployed|\bfde\b|palantir|foundry\s+(?:engineer|developer)|digital\s+product\s+(?:engineer|manager)|it\s+project\s+manager|it\s+manager)\b/i;
// Narrowed to the Basel region specifically (per the source's whole purpose — // Narrowed to the Basel region specifically (per the source's whole purpose —
// the inside contact is there), not all of Switzerland: Novartis CH postings // the inside contact is there), not all of Switzerland: Novartis CH postings
@@ -1552,3 +1554,232 @@ export async function fetchBaselJobs(): Promise<Job[]> {
return viable; return viable;
} }
// startup.jobs — global startup board, swept Switzerland-wide. Every path on
// the domain (listing, detail pages, feeds, even /robots-adjacent API routes)
// sits behind a Cloudflare managed challenge that 403s plain HTTP on any UA.
// Unlike basel-jobs.ch's DataDome cookie, the `cf_clearance` cookie does NOT
// replay over Bun's fetch (measured: 403 with the exact cookie + UA) — it is
// bound to Chrome's TLS fingerprint. What gets through:
// - Chrome `--headless=new` with a fresh profile passes the challenge, but
// only with the "HeadlessChrome" token stripped from the UA (403 otherwise);
// - the listings themselves come from Algolia (app 4CQMTMMK73, index
// Post_production), queried with a *secured* search key the page embeds in
// `meta[name="current-algolia-api-key-search"]`. algolia.net is not behind
// Cloudflare, so once the key is in hand the queries run over plain fetch.
// The key is minted per page load with `validUntil` ~3–5h out, so it is read
// fresh every run, never cached. That makes this source need a (headless)
// browser on every run — which, unlike basel-jobs.ch's visible-window mint,
// works fine under launchd.
//
// The secured key rejects every country filter (`country_code`, `country_id`
// both return 0), so the sweep is a geo bounding box around Switzerland with
// `country_code === "CH"` checked locally (the box also catches DE/FR/AT/LI/IT).
// Algolia caps a query at 500 retrievable hits (100 × 5 pages) while the box
// holds ~1400, so a box reporting > 500 is split into quadrants until each fits.
// Records carry no description; each title-filtered candidate's detail page is
// fetched through the browser (`page.evaluate(fetch)`, same TLS) and its
// JSON-LD `JobPosting.description` runs through isViableDescription in-process.
// Links already in results/jobs.csv are skipped before that fetch — daily runs
// 5×/day and the detail fetches are the only expensive part.
const STARTUPJOBS_ORIGIN = "https://startup.jobs";
const STARTUPJOBS_ALGOLIA = "https://4CQMTMMK73-dsn.algolia.net/1/indexes/Post_production/query";
const STARTUPJOBS_CH_BOX: [number, number, number, number] = [45.8, 5.9, 47.85, 10.5];
const STARTUPJOBS_MAX_AGE_DAYS = 60;
interface StartupJobsHit {
objectID: string;
title: string;
company_name: string;
country_code?: string;
location?: string;
path: string;
published_at_iso8601?: string;
}
function chromeUserAgent(): string | null {
const out = Bun.spawnSync([CHROME_BIN, "--version"]).stdout.toString();
const version = /(\d+\.\d+\.\d+\.\d+)/.exec(out)?.[1];
if (!version) return null;
return `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${version} Safari/537.36`;
}
async function startupJobsQuery(
key: string,
box: [number, number, number, number],
since: number,
page: number
): Promise<{ nbHits: number; nbPages: number; hits: StartupJobsHit[] }> {
const res = await fetch(STARTUPJOBS_ALGOLIA, {
method: "POST",
headers: { "X-Algolia-Application-Id": "4CQMTMMK73", "X-Algolia-API-Key": key },
body: JSON.stringify({
query: "",
insideBoundingBox: [box],
numericFilters: [`published_at_i>${since}`],
hitsPerPage: 100,
page,
attributesToRetrieve: ["title", "company_name", "country_code", "location", "path", "published_at_iso8601"],
attributesToHighlight: [],
}),
});
if (!res.ok) throw new Error(`algolia ${res.status}`);
return res.json();
}
async function startupJobsSweep(
key: string,
box: [number, number, number, number],
since: number,
out: Map<string, StartupJobsHit>,
depth = 0
): Promise<void> {
const first = await startupJobsQuery(key, box, since, 0);
if (first.nbHits > 500 && depth < 6) {
const [s, w, n, e] = box;
const midLat = (s + n) / 2;
const midLng = (w + e) / 2;
for (const q of [
[s, w, midLat, midLng], [s, midLng, midLat, e],
[midLat, w, n, midLng], [midLat, midLng, n, e],
] as [number, number, number, number][]) {
await startupJobsSweep(key, q, since, out, depth + 1);
}
return;
}
if (first.nbHits > 500) console.log(` warning: box ${box.join(",")} still holds ${first.nbHits} hits — truncated at 500`);
for (const h of first.hits) out.set(h.objectID, h);
for (let p = 1; p < first.nbPages; p++) {
const r = await startupJobsQuery(key, box, since, p);
for (const h of r.hits) out.set(h.objectID, h);
}
}
async function knownCsvLinks(): Promise<Set<string>> {
try {
const text = await Bun.file("results/jobs.csv").text();
return new Set(text.match(/https:\/\/startup\.jobs\/[^\s",]+/g) ?? []);
} catch {
return new Set();
}
}
export async function fetchStartupJobs(): Promise<Job[]> {
if (process.env.LINKME_NO_BROWSER === "1") {
console.log(" LINKME_NO_BROWSER=1 — startup.jobs unavailable");
return [];
}
const ua = (await Bun.file(CHROME_BIN).exists()) ? chromeUserAgent() : null;
if (!ua) {
console.error(` Chrome not found at ${CHROME_BIN} — startup.jobs unavailable`);
return [];
}
let chromium: typeof import("playwright-core").chromium;
try {
({ chromium } = await import("playwright-core"));
} catch {
console.error(" playwright-core not installed — run `bun add playwright-core`");
return [];
}
// Own port so it never collides with the DataDome mint (9333) running alongside.
const port = 9334;
const profile = `${tmpdir()}/linkme-startupjobs-${process.pid}`;
const proc = Bun.spawn(
[
CHROME_BIN,
"--headless=new",
`--user-agent=${ua}`,
`--remote-debugging-port=${port}`,
`--user-data-dir=${profile}`,
"--no-first-run",
"--no-default-browser-check",
"about:blank",
],
{ stdout: "ignore", stderr: "ignore" }
);
const jobs: Job[] = [];
try {
if (!(await waitForCdp(port, 20_000))) {
console.error(" Chrome CDP endpoint never came up — startup.jobs unavailable");
return [];
}
const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`);
try {
const page = browser.contexts()[0].pages()[0] ?? (await browser.contexts()[0].newPage());
await page.goto(STARTUPJOBS_ORIGIN, { timeout: 30_000 });
let key: string | null = null;
for (let i = 0; i < 15 && !key; i++) {
key = await page
.evaluate(() => document.querySelector('meta[name="current-algolia-api-key-search"]')?.getAttribute("content") ?? null)
.catch(() => null); // the challenge navigates mid-evaluate
if (!key) await page.waitForTimeout(1000);
}
if (!key) {
console.error(" Cloudflare challenge did not clear (no Algolia key) — startup.jobs unavailable");
return [];
}
const since = Math.floor(Date.now() / 1000) - STARTUPJOBS_MAX_AGE_DAYS * 86400;
const hits = new Map<string, StartupJobsHit>();
await startupJobsSweep(key, STARTUPJOBS_CH_BOX, since, hits);
const known = await knownCsvLinks();
const candidates = [...hits.values()].filter((h) => {
if (h.country_code !== "CH") return false;
const title = decodeHtml(h.title);
if (!applyFilters(title)) return false;
if (MS_STACK_RE.test(title) || BIO_DOMAIN_RE.test(title)) return false;
return !known.has(`${STARTUPJOBS_ORIGIN}${h.path}`);
});
console.log(` ${hits.size} in CH box, ${candidates.length} new title-matched candidates`);
let langDropped = 0;
for (const h of candidates) {
const description = await page
.evaluate(async (path) => {
const r = await fetch(path);
if (!r.ok) return null;
const doc = new DOMParser().parseFromString(await r.text(), "text/html");
for (const s of doc.querySelectorAll('script[type="application/ld+json"]')) {
try {
const data = JSON.parse(s.textContent ?? "");
for (const d of Array.isArray(data) ? data : [data]) {
if (d?.["@type"] === "JobPosting") return String(d.description ?? "");
}
} catch { /* malformed block */ }
}
return null;
}, h.path)
.catch(() => null);
if (description === null) continue; // detail page failed — retry next run
if (!isViableDescription(description).ok) { langDropped++; continue; }
jobs.push({
id: `sj-${h.objectID}`,
title: decodeHtml(h.title),
company: h.company_name ?? "",
location: h.location ?? "Switzerland",
postedAt: h.published_at_iso8601?.slice(0, 10) ?? "",
link: `${STARTUPJOBS_ORIGIN}${h.path}`,
search: "startup.jobs",
source: "startup.jobs",
contentChecked: true, // isViableDescription already ran on the JSON-LD description
});
await Bun.sleep(300);
}
console.log(` ${jobs.length} kept, ${langDropped} non-English / German-required`);
} finally {
await browser.close();
}
} catch (e) {
console.error(` startup.jobs failed: ${e}`);
} finally {
proc.kill();
await rm(profile, { recursive: true, force: true }).catch(() => {});
}
return jobs;
}
+3 -1
View File
@@ -2,7 +2,7 @@
import { searches } from "./config.ts"; import { searches } from "./config.ts";
import { parseCsvLine } from "./csv.ts"; import { parseCsvLine } from "./csv.ts";
import { fetchJobs, isViableContent, isViableUrl } from "./fetch.ts"; import { fetchJobs, isViableContent, isViableUrl } from "./fetch.ts";
import { fetchDataCareer, fetchStellenBasel, fetchItJobsCh, fetchJobRoom, fetchBaselIntlOrgs, fetchJobBasel, fetchEnglishJobSearch, fetchBaselJobs, fetchSciCore, fetchSwisscom, fetchNovartisBasel, fetchMoreJobs } from "./fetch-other.ts"; import { fetchDataCareer, fetchStellenBasel, fetchItJobsCh, fetchJobRoom, fetchBaselIntlOrgs, fetchJobBasel, fetchEnglishJobSearch, fetchBaselJobs, fetchSciCore, fetchSwisscom, fetchNovartisBasel, fetchMoreJobs, fetchStartupJobs } from "./fetch-other.ts";
const CSV = "./results/jobs.csv"; const CSV = "./results/jobs.csv";
const OLD_HEADER = "date_found,posted_at,search,title,company,link"; const OLD_HEADER = "date_found,posted_at,search,title,company,link";
@@ -191,6 +191,8 @@ const extraSources: Array<{ label: string; fetch: () => ReturnType<typeof fetchD
// Aggregator: reposts what LinkedIn/jobs.ch/etc. already carry, so also skip // Aggregator: reposts what LinkedIn/jobs.ch/etc. already carry, so also skip
// anything whose normalized title+company is already in the CSV. // anything whose normalized title+company is already in the CSV.
{ label: "more-jobs.ch", fetch: fetchMoreJobs, dedupeByTitle: true }, { label: "more-jobs.ch", fetch: fetchMoreJobs, dedupeByTitle: true },
// Aggregator of company ATS feeds (Wise, Stripe, Avaloq…) that LinkedIn also carries.
{ label: "startup.jobs", fetch: fetchStartupJobs, dedupeByTitle: true },
]; ];
async function processExtraSource({ label, fetch: fetchFn, dedupeByTitle }: (typeof extraSources)[number]): Promise<void> { async function processExtraSource({ label, fetch: fetchFn, dedupeByTitle }: (typeof extraSources)[number]): Promise<void> {