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 { tmpdir } from "os"; import { rm } from "fs/promises"; const HEADERS = { "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", "Accept-Language": "en-US,en;q=0.9", Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", }; // datacareer.ch inverts the usual bot rule: its nginx 403s anything claiming to // be a browser (a spoofed Chrome UA is the one thing guaranteed to fail) while // letting honest clients — no UA, curl, Googlebot — straight through. Presumably // real browser traffic arrives via a path that also carries matching TLS/CH // fingerprints, so a bare Chrome UA over plain HTTP reads as a scraper. Sending // an honest identifier is both what unblocks it and the polite thing to do. // This is what killed the source for months; it was never a hard block. const PLAIN_HEADERS = { "User-Agent": "linkme-jobbot/1.0 (personal job search; contact via basel)", "Accept-Language": "en-US,en;q=0.9", Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", }; function decodeHtml(s: string): string { return s .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/'/g, "'") .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n))) .replace(/\s+/g, " ") .trim(); } function applyFilters(title: string): boolean { if (NON_ENGLISH_RE.test(title)) return false; if (JUNIOR_TITLE_RE.test(title)) return false; if (!SOFTWARE_TITLE_RE.test(title)) return false; return true; } const MS_STACK_RE = /\.net\b|\bc#|sharepoint|dynamics 365|power platform|\bsccm\b|intune|successfactors|\bsap\b/i; // DE/FR indicator words, counted against a description we already have in hand. // Mirrors the ≥ 6 threshold isEnglishContent uses on fetched pages — but see // the adaptive threshold below for why short snippets need a lower bar. // du/dein*/dich/dir/einem/einer/eines/der/dem added 2026-09-08: job-room's // search API "Senior Fullstack Software Engineer" (Consult & Pepper) teaser — // "Du arbeitest in einem agilen Umfeld nach Scrum mit folgendem Tech-Stack: // ... TypeScript, Angular, HTML, CSS, PostgreSQL, ..." — is entirely German // but has zero umlauts and only one hit ("mit") on the original list, so it // sailed through as English despite jobs.ch itself showing "Deutsch // (Fliessend)" on the full posting. These are unambiguous German/French // function words with no common English collision. const DE_FR_WORDS_RE = /\b(und|oder|wir|Sie|Ihre|Ihrer|Kenntnisse|Erfahrung|Aufgaben|Anforderungen|Stelle|Unternehmen|Mitarbeiter|Bereich|nous|vous|votre|pour|avec|des|les|du|dein\w*|dich|dir|einem|einer|eines|der|dem)\b/g; // English-language AND not demanding German above B1 — mirrors isViableContent/ // isViableUrl in fetch.ts, but against a description we already have in hand // instead of a page fetch. The two checks catch different things: the word // count drops postings *written* in German/French; requiresGerman() drops // postings *written in English* that still demand German above B1 (e.g. // PRODYNA: "you can communicate fluently in both German and English" — clean // English text, would sail through the word count untouched). Skipping the // requiresGerman() half was a real gap in every source below until 2026-08-30 // — it let German-fluency requirements slip past whenever an item's inline // description read as English. function isViableDescription(description?: string): { ok: boolean; reason?: string } { if (!description) return { ok: true }; // nothing to judge — let the URL check in index.ts decide const text = description.replace(/<[^>]+>/g, " ").replace(/&[a-z]+;/g, " "); if (hasNonEnglishUmlaut(text)) return { ok: false, reason: "non-English (umlaut)" }; // job-room's search API returns a short keyword-anchored teaser (measured: // always < 500 chars), not the full posting — ≥6 is calibrated against full // page text and is unreachable on a couple of sentences, so a genuinely // German teaser can clear it on word count alone. Lower the bar for short // text; leave the full-page threshold (≥6) alone for longer descriptions, // where it was tuned to avoid false positives on English text. const threshold = text.length < 500 ? 3 : 6; if ((text.match(DE_FR_WORDS_RE) ?? []).length >= threshold) return { ok: false, reason: "non-English" }; if (requiresGerman(text)) return { ok: false, reason: "requires German > B1" }; return { ok: true }; } // datacareer.ch — HTML listing at /jobs/?city=Basel export async function fetchDataCareer(): Promise { const allJobs: Job[] = []; const seen = new Set(); for (let page = 1; page <= 10; page++) { if (page > 1) await Bun.sleep(400); process.stdout.write(` page ${page} … `); const res = await fetch( `https://www.datacareer.ch/jobs/?city=Basel&page=${page}`, { headers: PLAIN_HEADERS } ); if (!res.ok) { console.error(`failed (${res.status})`); break; } const html = await res.text(); const articles = [...html.matchAll(/]*listing-item[^>]*>(.*?)<\/article>/gs)]; if (articles.length === 0) { console.log("0 items"); break; } let newOnPage = 0; for (const [, card] of articles) { const titleM = card.match(/class="link"[^>]*>([^<]+)/); const hrefM = card.match(/href="(https:\/\/www\.datacareer\.ch\/job\/(\d+)\/[^"]*)"\s+class="link"/); if (!titleM || !hrefM) continue; const title = decodeHtml(titleM[1]); if (!applyFilters(title)) continue; const id = `dc-${hrefM[2]}`; if (seen.has(id)) continue; seen.add(id); const companyM = card.match(/listing-item__info--item-company[^>]*>\s*([^<\s][^<]*)/); const locM = card.match(/listing-item__info--item-location[^>]*>\s*([^<\s][^<]*)/); const dateM = card.match(/listing-item__date[^>]*>\s*([\d\/]+)/); // Date comes as DD/MM/YYYY → reformat to YYYY-MM-DD let postedAt = ""; const dm = dateM?.[1].match(/(\d{2})\/(\d{2})\/(\d{4})/); if (dm) postedAt = `${dm[3]}-${dm[2]}-${dm[1]}`; allJobs.push({ id, title, company: companyM ? decodeHtml(companyM[1].trim()) : "", location: locM ? locM[1].trim() : "Basel", postedAt, link: hrefM[1], search: "datacareer.ch", source: "datacareer.ch", }); newOnPage++; } console.log(`${articles.length} items, ${newOnPage} new`); if (articles.length < 8) break; } return allJobs; } // itjobs.ch — HTML scrape, jobs embedded as window.jobsList in page. // Swept Switzerland-wide (no location filter): the board is small, ~210 jobs // across 7 pages, and locking it to Basel returned only ~9. // Each item carries its full description, so the DE/FR content check runs here // instead of costing an extra page fetch per job in index.ts. export async function fetchItJobsCh(): Promise { const allJobs: Job[] = []; const seen = new Set(); for (let page = 1; page <= 25; page++) { if (page > 1) await Bun.sleep(400); process.stdout.write(` page ${page} … `); const res = await fetch( `https://www.itjobs.ch/jobs?page=${page}`, { headers: HEADERS } ); // The board 404s one page past the end rather than serving an empty list. if (!res.ok) { console.log(`end (${res.status})`); break; } const html = await res.text(); const blocks = [...html.matchAll(/window\.jobsList\s*=\s*window\.jobsList\.concat\((\[[\s\S]*?\])\);/g)]; if (!blocks.length) { console.log("0 items"); break; } const items: Array<{ id: number; title: string; description?: string; posted_at: string; job_details_path: string; employer: { name: string }; location?: string; job_location?: { name?: string }; }> = []; for (const [, json] of blocks) { try { items.push(...JSON.parse(json)); } catch { /* skip malformed block */ } } if (!items.length) { console.log("0 items"); break; } let newOnPage = 0; let langDropped = 0; for (const item of items) { const title = decodeHtml(item.title); if (!applyFilters(title)) continue; if (MS_STACK_RE.test(title)) continue; const id = `ij-${item.id}`; if (seen.has(id)) continue; seen.add(id); if (!isViableDescription(item.description).ok) { langDropped++; continue; } allJobs.push({ id, title, company: item.employer?.name ?? "", // job_location.name is the English form ("Aarau, Aargau, Switzerland"); // the bare location field is German ("Aarau, Kanton Aargau, Schweiz"). location: item.job_location?.name ?? item.location ?? "Switzerland", postedAt: item.posted_at?.slice(0, 10) ?? "", link: `https://www.itjobs.ch${item.job_details_path}`, search: "itjobs.ch", source: "itjobs.ch", contentChecked: true, // isViableDescription already ran on the real description }); newOnPage++; } console.log(`${items.length} items, ${newOnPage} new, ${langDropped} non-English`); if (items.length < 30) break; } return allJobs; } // swisscom.com — Swisscom's own Workday career site. Small board (~47 openings, // Switzerland-wide), so no keyword sweep — pull the full listing and filter // locally, like itjobs.ch. The listing page itself is a bare JS shell (no jobs // in the raw HTML); postings live behind the standard Workday CXS JSON API: // POST /wday/cxs/swisscom/SwisscomExternalCareers/jobs {appliedFacets, limit, offset, searchText} // `total` on the first page (offset 0) is authoritative — on later pages it // comes back as 0 (a quirk of this tenant), and an offset past the true total // silently wraps around and re-serves page 1 instead of an empty list. So the // loop's stopping condition is the first page's total, never a later one's. // Listing rows carry no description, so each title-filtered candidate costs // one extra fetch against the job detail endpoint (`/job{externalPath}`), // which returns the full HTML description — the DE/FR + requiresGerman check // runs on that here, same as job-room.ch/jobbasel.ch. const SWISSCOM_API_BASE = "https://swisscom.wd103.myworkdayjobs.com/wday/cxs/swisscom/SwisscomExternalCareers"; interface SwisscomListing { title: string; externalPath: string; postedOn: string; bulletFields: string[]; } function parseSwisscomPostedOn(text: string): string { const iso = (d: Date) => d.toISOString().slice(0, 10); const now = new Date(); if (/posted today/i.test(text)) return iso(now); if (/posted yesterday/i.test(text)) { const d = new Date(now); d.setDate(d.getDate() - 1); return iso(d); } const m = text.match(/posted (\d+) days? ago/i); if (m) { const d = new Date(now); d.setDate(d.getDate() - Number(m[1])); return iso(d); } return ""; // "Posted 30+ Days Ago" — no exact date available } export async function fetchSwisscom(): Promise { const listings: SwisscomListing[] = []; let total = Infinity; for (let offset = 0; offset < total; offset += 20) { if (offset > 0) await Bun.sleep(400); const res = await fetch(`${SWISSCOM_API_BASE}/jobs`, { method: "POST", headers: { ...HEADERS, "Content-Type": "application/json" }, body: JSON.stringify({ appliedFacets: {}, limit: 20, offset, searchText: "" }), }); if (!res.ok) { console.log(` Swisscom … failed (${res.status})`); break; } const data = (await res.json()) as { total: number; jobPostings: SwisscomListing[] }; if (offset === 0) total = data.total; if (!data.jobPostings.length) break; listings.push(...data.jobPostings); } const jobs: Job[] = []; let kept = 0; let langDropped = 0; for (const item of listings) { const title = decodeHtml(item.title); if (!applyFilters(title)) continue; if (MS_STACK_RE.test(title)) continue; await Bun.sleep(300); const detailRes = await fetch(`${SWISSCOM_API_BASE}${item.externalPath}`, { headers: HEADERS }); if (!detailRes.ok) continue; const detail = (await detailRes.json()) as { jobPostingInfo?: { location?: string; jobDescription?: string; externalUrl?: string }; }; const info = detail.jobPostingInfo; if (!isViableDescription(info?.jobDescription).ok) { langDropped++; continue; } jobs.push({ id: `sc-${item.bulletFields?.[0] ?? item.externalPath}`, title, company: "Swisscom", location: info?.location ?? "Switzerland", postedAt: parseSwisscomPostedOn(item.postedOn), link: info?.externalUrl ?? `https://swisscom.wd103.myworkdayjobs.com/SwisscomExternalCareers${item.externalPath}`, search: "swisscom.com", source: "swisscom.com", contentChecked: true, // isViableDescription already ran on the real description }); kept++; } console.log(` Swisscom … ${listings.length} items, ${kept} kept, ${langDropped} non-English`); return jobs; } // job-room.ch — the federal employment service board (RAV / Arbeitsvermittlung). // Roles under the Stellenmeldepflicht must be posted here before anywhere else, // so it often carries a job days ahead of LinkedIn. // // Quirks worth knowing: // - Each hit is wrapped: [{ jobAdvertisement: {...} }, …] // - Titles come back with highlighting around the matched keyword. // - `languageIsoCode` on a description is unreliable — plenty of records tagged // "de" carry English text (Roche, Novartis, Bachem all do). The DE/FR word // count is what actually decides, so we ignore the tag. // - An unfiltered canton sweep returns thousands of ads across every industry, // so we narrow by keyword instead and union the results. // - Descriptions arrive inline, so the language check runs here rather than // costing a page fetch per job in index.ts. const JOBROOM_API = "https://www.job-room.ch/jobadservice/api/jobAdvertisements/_search"; const JOBROOM_CANTONS = ["BS", "BL", "BE", "ZH"]; const JOBROOM_KEYWORDS = [ "software", "engineer", "developer", "devops", "data", "python", "cloud", "platform", "typescript", "backend", "full stack", "ai", "agentic", ]; // job-room carries every industry; pharma/lab titles slip past SOFTWARE_TITLE_RE // via words like "data", "director" and "technical". const PHARMA_ROLE_RE = /\b(clinical|pharmaceutical|drug\s+(substance|product)|ha?ematology|oncology|formulation|laborator|lab\s+technician|chemist|chemistry|biolog|preclinical|regulatory\s+affairs|medical\s+affairs|commercial\s+launch|veterinary|pathologist)\b/i; interface JobRoomAd { id: string; createdTime?: string; jobContent?: { externalUrl?: string | null; jobDescriptions?: Array<{ title: string; description?: string }>; company?: { name?: string; city?: string }; location?: { city?: string; cantonCode?: string }; languageSkills?: JobRoomLanguageSkill[]; }; } interface JobRoomLanguageSkill { languageIsoCode?: string; spokenLevel?: string; writtenLevel?: string; } // job-room's language requirement is a structured field, not prose — it never // touches jobDescriptions[].description, so isViableDescription()/requiresGerman() // can't see it. The UI renders it as e.g. "Deutsch (Fliessend)"; PROFICIENT is // that "Fliessend" (fluent/C1+) tier, well above Rafael's German B1. BASIC and // INTERMEDIATE stay in scope. function jobRoomRequiresGerman(skills?: JobRoomLanguageSkill[]): boolean { if (!skills) return false; return skills.some( (s) => s.languageIsoCode === "de" && (s.spokenLevel === "PROFICIENT" || s.writtenLevel === "PROFICIENT") ); } // jobs.ch links arrive with a tracking query string that would break dedup // across runs if it ever changes; the path alone identifies the posting. function stripTracking(url: string): string { try { const u = new URL(url); for (const k of [...u.searchParams.keys()]) { if (k.startsWith("utm_")) u.searchParams.delete(k); } return u.toString().replace(/\?$/, ""); } catch { return url; } } export async function fetchJobRoom(): Promise { const ads = new Map(); // The search API returns a short keyword-anchored teaser per hit, not the // full posting — the same ad re-appears across keyword sweeps with a // different teaser each time, and `ads.set()` above only keeps the latest. // A genuinely German ad (Consult & Pepper's recurring "Senior Fullstack // Software Engineer" — full text: "In Deutsch bist du verhandlungssicher") // slipped through repeatedly because whichever keyword ran last happened to // anchor on an English-reading fragment of it. Collecting every variant and // checking them together means one clean-looking teaser can no longer // override another teaser that actually shows the German. const descVariants = new Map>(); for (const keyword of JOBROOM_KEYWORDS) { process.stdout.write(` ${keyword} … `); let total = 0; for (let page = 0; page < 8; page++) { if (page > 0) await Bun.sleep(250); const res = await fetch(`${JOBROOM_API}?page=${page}&size=100`, { method: "POST", headers: { ...HEADERS, "Content-Type": "application/json", Accept: "application/json" }, body: JSON.stringify({ onlineSince: 30, displayRestricted: false, keywords: [keyword], cantonCodes: JOBROOM_CANTONS, workloadPercentageMin: 0, workloadPercentageMax: 100, }), }); if (!res.ok) { console.log(`failed (${res.status})`); break; } const hits = (await res.json()) as Array<{ jobAdvertisement?: JobRoomAd }>; for (const h of hits) { const id = h.jobAdvertisement?.id; if (!id) continue; ads.set(id, h.jobAdvertisement!); const d = h.jobAdvertisement!.jobContent?.jobDescriptions?.[0]?.description; if (d) (descVariants.get(id) ?? descVariants.set(id, new Set()).get(id)!).add(d); } total += hits.length; if (hits.length < 100) break; } console.log(`${total} ads (${ads.size} unique)`); await Bun.sleep(250); } const jobs: Job[] = []; let langDropped = 0; for (const ad of ads.values()) { const content = ad.jobContent; const desc = content?.jobDescriptions?.[0]; if (!desc) continue; // Strip the highlighting the search API wraps around matched terms. const title = decodeHtml(desc.title.replace(/<[^>]+>/g, "")); if (!applyFilters(title)) continue; if (MS_STACK_RE.test(title)) continue; if (PHARMA_ROLE_RE.test(title)) continue; // Structured language requirement — separate from the free-text description // entirely, so it must be checked even when the description reads clean. if (jobRoomRequiresGerman(content?.languageSkills)) { langDropped++; continue; } // A handful of ads ship without a description. isViableDescription passes // those through by design, so leave contentChecked unset and let index.ts // fall back to fetching the page. When we do have variants, join every // one seen across keyword sweeps rather than just the last teaser stored // on `ad` (see comment above) — more text only adds detection signal, it // never removes any. const body = [...(descVariants.get(ad.id) ?? [])].join(" \n ") || desc.description || ""; const hasBody = body.trim().length > 40; if (hasBody && !isViableDescription(body).ok) { langDropped++; continue; } const city = content?.location?.city ?? content?.company?.city ?? ""; const canton = content?.location?.cantonCode ?? ""; jobs.push({ id: `jr-${ad.id}`, title, company: content?.company?.name ?? "", location: [city, canton].filter(Boolean).join(", ") || "Switzerland", postedAt: ad.createdTime?.slice(0, 10) ?? "", link: content?.externalUrl ? stripTracking(content.externalUrl) : `https://www.job-room.ch/job-search/${ad.id}`, search: "job-room.ch", source: "job-room.ch", contentChecked: hasBody, // only skip the re-check when we really judged a description }); } console.log(` ${ads.size} ads scanned, ${jobs.length} kept, ${langDropped} non-English`); return jobs; } // International organisations in the Basel region (BIS, Swiss TPH, Basel // Institute on Governance). Each board carries only a handful of postings, so // they're swept together under one label rather than one entry apiece. // // These are mission-driven employers whose tech roles use UN-flavoured titles // ("Information Management Officer", "ICT Specialist") that SOFTWARE_TITLE_RE // alone would drop, so INTL_TECH_RE widens the net for these boards only. // Boards this small can afford the extra noise; missing a rare opening can't. const INTL_TECH_RE = /\b(information\s*(management|systems?|technology)|ict\b|digital|geospatial|\bmis\b|data\s*(management|steward))\b/i; function applyIntlFilters(title: string): boolean { if (NON_ENGLISH_RE.test(title)) return false; if (JUNIOR_TITLE_RE.test(title)) return false; if (MS_STACK_RE.test(title)) return false; return SOFTWARE_TITLE_RE.test(title) || INTL_TECH_RE.test(title); } // BIS runs a JS-rendered career site with no usable API, so we read the UNjobs // mirror of its Basel office instead. Titles arrive as "Role, City, Country". async function fetchBis(): Promise { const jobs: Job[] = []; const res = await fetch( "https://unjobs.org/offices/bis_182320855689338880", { headers: HEADERS } ); if (!res.ok) { console.log(` BIS … failed (${res.status})`); return jobs; } const html = await res.text(); const cards = [...html.matchAll( /]+href="(https:\/\/unjobs\.org\/vacancies\/(\d+))"[\s\S]*?([^<]+)<\/span>([\s\S]*?)<\/a>/g )]; let kept = 0; for (const [, link, id, rawTitle, rest] of cards) { // "Senior Platform Engineer, Basel, Switzerland" → title + location const split = decodeHtml(rawTitle).match(/^(.*?),\s*([^,]+,\s*[^,]+)$/); const title = split ? split[1] : decodeHtml(rawTitle); const location = split ? split[2] : "Basel, Switzerland"; if (!applyIntlFilters(title)) continue; jobs.push({ id: `bis-${id}`, title, company: "Bank for International Settlements", location, postedAt: rest.match(/datetime="(\d{4}-\d{2}-\d{2})/)?.[1] ?? "", link, search: "bis.org", source: "bis.org", contentChecked: true, // BIS publishes in English only }); kept++; } console.log(` BIS … ${cards.length} ads, ${kept} kept`); return jobs; } // Swiss TPH runs Umantis. Site chrome is German even on English postings, so // isEnglishUrl would reject the lot — the title filters decide instead. async function fetchSwissTph(): Promise { const jobs: Job[] = []; const res = await fetch("https://jobs.swisstph.ch/Jobs/All", { headers: HEADERS }); if (!res.ok) { console.log(` Swiss TPH … failed (${res.status})`); return jobs; } const html = await res.text(); // One block per posting, opened by the subtitle link that carries the title. const blocks = html.split(/(?=]*class="HSTableLinkSubTitle")/).slice(1); let kept = 0; for (const block of blocks) { const hrefM = block.match(/href="(\/Vacancies\/(\d+)\/Description\/\d+)"/); const titleM = block.match(/class="HSTableLinkSubTitle"[^>]*>([^<]+)[\s\S]{0,300}?>\s*(\d{2})\.(\d{2})\.(\d{4})/); const locM = block.match(/>\s*Schweiz\s*-\s*([^<]+) { const jobs: Job[] = []; const res = await fetch("https://baselgovernance.org/about/careers", { headers: HEADERS }); if (!res.ok) { console.log(` Basel Governance … failed (${res.status})`); return jobs; } const html = await res.text(); // The `rounded-3xl` card class is what separates vacancy tiles from other // asset links on the page (the annual report PDF sits in a plain anchor). const cards = [...html.matchAll( /]*class="[^"]*rounded-3xl[^"]*"[\s\S]{0,400}?]*>([^<]+)<\/h2>([\s\S]{0,900})/g )]; let kept = 0; for (const [, link, assetId, rawTitle, rest] of cards) { const title = decodeHtml(rawTitle); if (!applyIntlFilters(title)) continue; const locM = rest.match(/map-pin[\s\S]{0,200}?<\/span>\s*([^<]+) { const jobs: Job[] = []; for (const fetchOrg of [fetchBis, fetchSwissTph, fetchBaselGovernance]) { try { jobs.push(...(await fetchOrg())); } catch (e) { console.log(` error: ${e}`); } await Bun.sleep(400); } return jobs; } // sciCORE's open-positions page (formerly polled separately by // scripts/scicore-watch.sh) is a plain-text announcement list, not a job // board: each
  • is either an active posting linked to careers.unibas.ch // or a closed/in-review notice with no link at all. Skip the latter — "the // interview process is still ongoing, but the submission deadline has // passed" is the site's own wording for "not accepting applications" — since // they're not something Rafael could still apply to. export async function fetchSciCore(): Promise { const jobs: Job[] = []; const url = "https://scicore.unibas.ch/about-scicore/open-positions/"; const res = await fetch(url, { headers: HEADERS }); if (!res.ok) { console.log(` sciCORE … failed (${res.status})`); return jobs; } const html = await res.text(); const sectionM = html.match(/

    Join the team!<\/h1>([\s\S]*?)