Compare commits
25
Commits
d2aa61c8ad
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a2d053ca7 | ||
|
|
b05bddb73d | ||
|
|
b5cf8931ea | ||
|
|
73840cad04 | ||
|
|
aeb16de5ed | ||
|
|
63f84b6d04 | ||
|
|
94febd5001 | ||
|
|
069b371be9 | ||
|
|
d61e5b7615 | ||
|
|
bac6555c10 | ||
|
|
e70ae1e932 | ||
|
|
ab659008e6 | ||
|
|
01dd3861b6 | ||
|
|
74f8df0b15 | ||
|
|
dfba00ae91 | ||
|
|
3875f13713 | ||
|
|
288b8bdcd1 | ||
|
|
9cbc525cf5 | ||
|
|
29b00cd91e | ||
|
|
d401dd5f9a | ||
|
|
0249800278 | ||
|
|
c7b86e06f4 | ||
|
|
821bd19ff7 | ||
|
|
a72a31f387 | ||
|
|
e36bcdeb3a |
+3
-5
@@ -4,9 +4,7 @@ node_modules/
|
|||||||
|
|
||||||
# job data and personal notes
|
# job data and personal notes
|
||||||
results/
|
results/
|
||||||
jobs/
|
# symlinks into the private ../rafapolo/job/ (gitignored there too)
|
||||||
|
jobs
|
||||||
|
cv
|
||||||
|
|
||||||
# CV files (large binaries + personal)
|
|
||||||
cv/*.pdf
|
|
||||||
cv/*.odt
|
|
||||||
cv/*.html
|
|
||||||
|
|||||||
@@ -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)
|
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 + data-jobs.ch, scicore.unibas.ch, swisscom.com, novartis.com, more-jobs.ch, startup.jobs, arbeitnow.ch)
|
||||||
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
|
||||||
@@ -30,20 +30,23 @@ telegram.ts # Telegram delivery, chunking, --setup chat-ID discovery
|
|||||||
scripts/ # one-off search scripts; import from ../config.ts and ../fetch.ts
|
scripts/ # one-off search scripts; import from ../config.ts and ../fetch.ts
|
||||||
scripts/scicore-watch.sh # daily launchd watch on sciCORE's open-positions page
|
scripts/scicore-watch.sh # daily launchd watch on sciCORE's open-positions page
|
||||||
results/sent.txt # links already pushed to Telegram — never sent twice
|
results/sent.txt # links already pushed to Telegram — never sent twice
|
||||||
|
cv/ → ../rafapolo/job/cv/ # symlink; CVs (cv-source.md is canonical, polo.md is the ranking profile)
|
||||||
cv/polo.md # Rafael's profile — use for ranking jobs
|
cv/polo.md # Rafael's profile — use for ranking jobs
|
||||||
cv/ # CV files (html, pdf, odt)
|
|
||||||
results/jobs.csv # output — one row per job
|
results/jobs.csv # output — one row per job
|
||||||
jobs/ # notes on specific job leads
|
jobs/ → ../rafapolo/job/applications/ # symlink; one folder per company: cover/motivation letters, dossiers, notes
|
||||||
plans/ # LinkedIn post drafts
|
plans/ # LinkedIn post drafts
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- **`config.ts`** — `KEYWORDS` string (joined with ` OR `) and `searches: SearchConfig[]`. Edit here to add/remove search areas or keywords.
|
- **`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.
|
- Also exports `PLATFORM_KEYWORDS` (architect/SRE/infra/security/MLOps), `RESEARCH_KEYWORDS` (research & scientific software, HPC, data platform), `FIELD_KEYWORDS` (forward-deployed, customer/sales/integration, founding/product) and `FRONTEND_KEYWORDS` (frontend/React/web, data visualisation, geospatial/GIS, WebGL — added 2026-10-20 after a one-off search found 14 of 18 relevant frontend/viz roles missing from the CSV). 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, Field and Frontend 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). 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 + data-jobs.ch, scicore.unibas.ch, swisscom.com, novartis.com, more-jobs.ch, startup.jobs, arbeitnow.ch). 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 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.
|
||||||
- **datacareer.ch** inverts the usual bot rule: its nginx **403s browser-like User-Agents** and serves honest ones (no UA, `curl/8.0`, Googlebot) fine. The spoofed Chrome UA in the shared `HEADERS` was the entire cause of the long-standing 403 — it uses `PLAIN_HEADERS` instead. Small board: Basel returns **8 jobs on one page with no pagination**, so `?page=2` legitimately yields 0 items.
|
- **datacareer.ch** inverts the usual bot rule: its nginx **403s browser-like User-Agents** and serves honest ones (no UA, `curl/8.0`, Googlebot) fine. The spoofed Chrome UA in the shared `HEADERS` was the entire cause of the long-standing 403 — it uses `PLAIN_HEADERS` instead. Small board: Basel returns **8 jobs on one page with no pagination**, so `?page=2` legitimately yields 0 items.
|
||||||
- **job-room.ch** carries its language requirement as a **structured field** (`languageSkills[]`, e.g. `{languageIsoCode: "de", spokenLevel: "PROFICIENT"}`), entirely separate from the free-text `jobDescriptions[].description` — the UI renders it as "Deutsch (Fliessend)". `requiresGerman()`/`isViableDescription()` only ever scan the prose, so this field slipped through unchecked until 2026-09-04: measured against 1843 ads, 69 required PROFICIENT-level German and the prose check caught only 3 of them incidentally. `jobRoomRequiresGerman()` now checks the structured field directly (`spokenLevel`/`writtenLevel === "PROFICIENT"`) before the description-based check runs. Levels seen: `NONE`, `BASIC`, `INTERMEDIATE`, `PROFICIENT` — only `PROFICIENT` (fluent/C1+) is dropped, matching the B1 cutoff used everywhere else.
|
- **job-room.ch** carries its language requirement as a **structured field** (`languageSkills[]`, e.g. `{languageIsoCode: "de", spokenLevel: "PROFICIENT"}`), entirely separate from the free-text `jobDescriptions[].description` — the UI renders it as "Deutsch (Fliessend)". `requiresGerman()`/`isViableDescription()` only ever scan the prose, so this field slipped through unchecked until 2026-09-04: measured against 1843 ads, 69 required PROFICIENT-level German and the prose check caught only 3 of them incidentally. `jobRoomRequiresGerman()` now checks the structured field directly (`spokenLevel`/`writtenLevel === "PROFICIENT"`) before the description-based check runs. Levels seen: `NONE`, `BASIC`, `INTERMEDIATE`, `PROFICIENT` — only `PROFICIENT` (fluent/C1+) is dropped, matching the B1 cutoff used everywhere else.
|
||||||
- **jobbasel.ch** looks like a JS-only React/Vite SPA (no jobs in the homepage's raw HTML), but its search route is server-rendered: `GET /job/suche-{slug}` returns full HTML with a `window.__PRELOADED_STATE__` blob containing the matched vacancies as JSON — no headless browser needed. Pagination is a URL suffix (`-seite-{n}`); a bare `?page=` query param is silently ignored. Swept with the same fixed keyword list as job-room.ch (no keyword-union endpoint exists) and deduped by vacancy id. Each item carries its full German description, so the DE/FR check runs in-process.
|
- **jobbasel.ch** looks like a JS-only React/Vite SPA (no jobs in the homepage's raw HTML), but its search route is server-rendered: `GET /job/suche-{slug}` returns full HTML with a `window.__PRELOADED_STATE__` blob containing the matched vacancies as JSON — no headless browser needed. Pagination is a URL suffix (`-seite-{n}`); a bare `?page=` query param is silently ignored. Swept with the same fixed keyword list as job-room.ch (no keyword-union endpoint exists) and deduped by vacancy id. Each item carries its full German description, so the DE/FR check runs in-process.
|
||||||
@@ -75,6 +78,8 @@ plans/ # LinkedIn post drafts
|
|||||||
|
|
||||||
Pagination/dedup: page 4+ of the category still serves 2 sponsored slots, so **"no new ids" is the end condition**, not an empty list. Sponsored cards repeat within a page, across pages, and across both passes — dedupe on `data-gtm-jid`. Cards carry no date, so `postedAt` is the discovery date. Canton comes from `data-gtm-jp` (sometimes `"BL,BS"`).
|
Pagination/dedup: page 4+ of the category still serves 2 sponsored slots, so **"no new ids" is the end condition**, not an empty list. Sponsored cards repeat within a page, across pages, and across both passes — dedupe on `data-gtm-jid`. Cards carry no date, so `postedAt` is the discovery date. Canton comes from `data-gtm-jp` (sometimes `"BL,BS"`).
|
||||||
|
|
||||||
|
**Pass 3: data-jobs.ch** (added 2026-10-22) — the same jobchannel network's data/analytics board, swept for BS/BL/ZH/BE/LU (`DATAJOBS_PROVINCES`) inside `fetchBaselJobs`: same card markup and parser, and the same DataDome cookie clears both hosts (a cookie rotated by one host works on the other). `/search?provinces=…&lang=en-ch&page=n` walks the board's own listings (100 per page; past the end it still serves a few sponsored cards, so "no new ids" ends the walk), then one `&page=1&websearch=1` request returns all the "further jobs from the web search" (it ignores `page`). Measured 2026-10-22: 166 own + 98 web search = 264 unique for the five cantons. Vacancy ids are network-wide, so it runs last and shares `seen` — a posting on both boards keeps its basel-jobs.ch link. Its inventory is **not** a subset of basel-jobs.ch (data-only postings 404 there): measured 2026-10-22, 26 ids the basel-jobs passes missed. A posting on both boards keeps the same `/job/<slug>/<id>` path, so `knownJobchannelTails()` skips any card whose path is already in the CSV under either host — before its detail fetch, not after. Re-checking every known posting on every run is what got the cookie blocked on the first five-canton test (2026-10-22). For the same reason a detail page that fails to load is now **skipped** (retried next run) rather than kept unchecked: a blocked cookie fails every remaining page at once. Rows get `source`/`search` = `data-jobs.ch`.
|
||||||
|
|
||||||
**Language-check the `div.plain-content` block only, never the whole page.** The site chrome is German and on its own clears the ≥ 6 DE/FR word threshold — checking the full page dropped *every* job, English ones included. That block is the mobile rendering of the description; the desktop view iframes the same text, so it's the only copy in the server-rendered HTML.
|
**Language-check the `div.plain-content` block only, never the whole page.** The site chrome is German and on its own clears the ≥ 6 DE/FR word threshold — checking the full page dropped *every* job, English ones included. That block is the mobile rendering of the description; the desktop view iframes the same text, so it's the only copy in the server-rendered HTML.
|
||||||
- **Basel international orgs** (`fetchBaselIntlOrgs`) sweeps three boards under one label, each only a handful of postings:
|
- **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.
|
- **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.
|
||||||
@@ -82,10 +87,19 @@ plans/ # LinkedIn post drafts
|
|||||||
- **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.
|
- **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.
|
- 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.
|
- **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.
|
||||||
|
- **scicore.unibas.ch** (`fetchSciCore`) replaces the standalone `scripts/scicore-watch.sh` launchd watcher (removed 2026-09-16) — sciCORE now runs inside `bun fetch`/`bun daily` like every other source instead of its own 08:47 agent. The open-positions page is a plain-text announcement list, not a real job board: each `<li>` under `<h1>Join the team!</h1>` is either an active posting linked to `careers.unibas.ch` or a closed/in-review notice with no link — items matching `deadline has passed`/`interview process is (still) ongoing` are skipped since they're not open applications. `contentChecked: true` because the listing itself is the only English text available; there's no separate description page to fetch. Title still has to clear `SOFTWARE_TITLE_RE`, same as every other source — this is deliberately broader than the old watcher's "software"-only match (it also catches "security", matching the IT Security Engineer role Rafael prepped for — see `project_scicore_security_report` in memory) but still drops pure sysadmin titles like "Linux Systems Administrator".
|
||||||
|
- **swisscom.com** (`fetchSwisscom`, added 2026-09-18) — Swisscom's own Workday career site (`swisscom.wd103.myworkdayjobs.com/SwisscomExternalCareers`). Small board (~47 openings, Switzerland-wide), so no keyword sweep — the full listing is pulled and filtered locally, like itjobs.ch. The listing page itself is a bare JS shell with no jobs in the raw HTML; postings live behind the standard Workday CXS JSON API (`POST /wday/cxs/swisscom/SwisscomExternalCareers/jobs`). `total` on the first page (offset 0) is authoritative — later pages report `total: 0` (a tenant quirk), and an offset past the true total silently wraps around and re-serves page 1 instead of an empty list, so the loop's stop 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 in-process (`contentChecked: true`).
|
||||||
|
|
||||||
|
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).
|
||||||
|
- **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.
|
||||||
|
- **arbeitnow.ch** (`fetchArbeitnow`, added 2026-11-03) — free public JSON API (`/api/job-board-api?page=n`, 100/page, no auth), fed straight from company ATS feeds. Rolling ~7-day window (~195 jobs), so 2 requests cover it. Full description inline → in-process DE/FR + `requiresGerman` check. `dedupeByTitle` (overlaps ~50% with LinkedIn). **pilotjob.ch was evaluated and rejected**: its listing only offers `src=arbeitnow|jobroom`, i.e. it re-serves this feed plus job-room.ch. englishjobsearch.ch/jobs/remote shares only 1 of arbeitnow's 19 remote postings.
|
||||||
- **`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.
|
||||||
- **`daily.ts`** — the notification layer on top of `index.ts`. Snapshots the CSV's links, shells out to `bun fetch`, then diffs to find what's new. Survivors are filtered through `matchesProfile` and grouped by area (Basel / Remote / Zürich / Bern) so the message scans on a phone. `results/sent.txt` is written **only after a successful send**, so a Telegram failure retries next run instead of silently losing jobs. A non-zero exit from `bun fetch` sends a failure notice rather than failing quietly.
|
- **`daily.ts`** — the notification layer on top of `index.ts`. Snapshots the CSV's links, shells out to `bun fetch`, then diffs to find what's new. Survivors are filtered through `matchesProfile` and grouped by area (Basel / Remote / Zürich / Bern / Luzern) so the message scans on a phone. `results/sent.txt` is written **only after a successful send**, so a Telegram failure retries next run instead of silently losing jobs. A non-zero exit from `bun fetch` sends a failure notice rather than failing quietly.
|
||||||
- The area grouping matches against the CSV's `location` column (the posting's real location) first, falling back to `search` (the query/source label) only when `location` is empty — i.e. for rows written before the `location` column existed. This fixed two real mislabels: a LinkedIn "Bern" sweep can surface a job actually based in Lausanne (search label ≠ real location), and `fetch-other.ts` sources (job-room.ch, itjobs.ch, stellen-basel.ch, datacareer.ch) write the source name into `search` instead of a city, which used to dump their jobs into "⚪️ Other" regardless of where they actually were.
|
- The area grouping matches against the CSV's `location` column (the posting's real location) first, falling back to `search` (the query/source label) only when `location` is empty — i.e. for rows written before the `location` column existed. This fixed two real mislabels: a LinkedIn "Bern" sweep can surface a job actually based in Lausanne (search label ≠ real location), and `fetch-other.ts` sources (job-room.ch, itjobs.ch, stellen-basel.ch, datacareer.ch) write the source name into `search` instead of a city, which used to dump their jobs into "⚪️ Other" regardless of where they actually were.
|
||||||
- **`profile.ts`** — `matchesProfile(title, company, minScore = 4)`. Hard rejects run first (Microsoft stack, Java/C++/SAP, life-science, non-software functions, blocked companies Jobgether and ALLPS), then `SIGNALS` scores stack and seniority terms from `cv/polo.md`. This is a **second, stricter gate than the fetch filters**: `fetch.ts` decides what lands in the CSV, `profile.ts` decides what's worth a phone buzz. Widen `SOFTWARE_TITLE_RE` to catch more in the CSV; tune `SIGNALS`/`minScore` to change what gets sent.
|
- **`profile.ts`** — `matchesProfile(title, company, minScore = 4)`. Hard rejects run first (Microsoft stack, Java/C++/SAP, life-science, non-software functions, blocked companies Jobgether and ALLPS), then `SIGNALS` scores stack and seniority terms from `cv/polo.md`. This is a **second, stricter gate than the fetch filters**: `fetch.ts` decides what lands in the CSV, `profile.ts` decides what's worth a phone buzz. Widen `SOFTWARE_TITLE_RE` to catch more in the CSV; tune `SIGNALS`/`minScore` to change what gets sent.
|
||||||
- **`telegram.ts`** — reads `.env` directly (no dotenv dep). Accepts `TELEGRAM_BOT` or `TELEGRAM_TOKEN` for the token. Splits at 3900 chars on line boundaries (Telegram hard-caps at 4096) and sleeps 400 ms between parts to stay under the rate limit. Messages are HTML — run titles/companies through `escapeHtml`.
|
- **`telegram.ts`** — reads `.env` directly (no dotenv dep). Accepts `TELEGRAM_BOT` or `TELEGRAM_TOKEN` for the token. Splits at 3900 chars on line boundaries (Telegram hard-caps at 4096) and sleeps 400 ms between parts to stay under the rate limit. Messages are HTML — run titles/companies through `escapeHtml`.
|
||||||
@@ -170,6 +184,12 @@ Extra filters for temp scripts (import from `../fetch.ts`):
|
|||||||
- `isEnglishContent(id)` — drop DE/FR description content
|
- `isEnglishContent(id)` — drop DE/FR description content
|
||||||
- Add your own inline RE for EMEA, .NET, etc.
|
- Add your own inline RE for EMEA, .NET, etc.
|
||||||
|
|
||||||
|
## Application history (`jobs/`, `cv/`)
|
||||||
|
|
||||||
|
`cv/` and `jobs/` are symlinks into the **private** `../rafapolo/job/` (gitignored in both repos; `rafapolo` is a public GitHub Pages site, so never move these into a non-ignored path there). Everything else about past applications lives beside them: `../rafapolo/job/career-strategy-notes.md`, `credentials/` (certs, diplomas, references) and the dossier PDFs at the root.
|
||||||
|
|
||||||
|
Before presenting a job or writing a letter, check `jobs/<company-slug>/` (e.g. `ls jobs | grep -i roche`): if a folder exists, Rafael already applied there — say so, reuse the earlier letter as a starting point, and mention any track record in memory (e.g. Adobe and BIS cold applies are dead). New letters go in `jobs/<company-slug>/`.
|
||||||
|
|
||||||
## Ranking jobs against the CV
|
## Ranking jobs against the CV
|
||||||
|
|
||||||
Read `cv/polo.md` for the full profile. Key signals:
|
Read `cv/polo.md` for the full profile. Key signals:
|
||||||
@@ -203,24 +223,16 @@ Some employers (Optiml, LocalBini seen repeatedly) repost the same role under a
|
|||||||
|
|
||||||
## Scheduled automations
|
## Scheduled automations
|
||||||
|
|
||||||
Both run as **launchd agents** — this machine has no cron at all, so don't reach for `crontab`.
|
Runs as a **launchd agent** — this machine has no cron at all, so don't reach for `crontab`.
|
||||||
|
|
||||||
| plist (`~/Library/LaunchAgents/`) | when | runs |
|
| plist (`~/Library/LaunchAgents/`) | when | runs |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `com.rafapolo.linkme-daily` | 08:00 + 18:00 | `bun run daily.ts` → `results/daily.log` |
|
| `com.rafapolo.linkme-daily` | 09:00 / 12:00 / 15:00 / 17:00 / 20:00 | `bun run daily.ts` → `results/daily.log` |
|
||||||
| `ch.polo.scicore-watch` | 08:47 | `scripts/scicore-watch.sh` → `results/.scicore-watch/watch.log` |
|
| `com.rafapolo.linkme-watch-delivery-lead` | 10:00 | `bun run scripts/watch-delivery-lead.ts` → `results/watch-delivery-lead.log`; watches for the MRP-Global "Delivery Lead – Agentic Solutions" opening (LinkedIn 4473851998) resurfacing elsewhere and for the original closing. Telegram only on a hit. Unload once the lead is resolved |
|
||||||
|
|
||||||
If the Mac is asleep at the scheduled time, launchd fires the job on wake rather than skipping the day. `launchctl list | grep -E 'linkme|scicore'` to check they're loaded; `launchctl unload <plist>` to stop one.
|
If the Mac is asleep at the scheduled time, launchd fires the job on wake rather than skipping the day. `launchctl list | grep linkme` to check it's loaded; `launchctl unload <plist>` to stop it.
|
||||||
|
|
||||||
**`scripts/scicore-watch.sh`** watches https://scicore.unibas.ch/about-scicore/open-positions/ for a software engineering role Rafael is waiting on. It extracts the "Join the team!" block (keeping each posting's `<a href>` so the alert is tappable), diffs it against `results/.scicore-watch/snapshot.txt`, and notifies **only when a newly added line matches `$WANTED` ("software")**. Every other change still updates the snapshot silently — that's what keeps the eventual real alert clean instead of tangled with months of sysadmin churn. Three consecutive fetch failures alert, so a broken watcher can't look like "no news".
|
**`scripts/scicore-watch.sh`** is retired (2026-09-16) — sciCORE is now swept by `fetchSciCore` in `fetch-other.ts` as part of `bun fetch`/`bun daily`, see the `fetch-other.ts` entry above. Its `ch.polo.scicore-watch` launchd agent (08:47) was unloaded and its plist removed; the script and `results/.scicore-watch/` (snapshot + log) are left in the repo for reference/history, not run anymore.
|
||||||
|
|
||||||
Test the alert path without waiting for the page to change:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SCICORE_URL="file://$PWD/fixture.html" bash scripts/scicore-watch.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
The whole extractor is a `python3 -c` program inside shell single quotes, so **it must not contain a literal apostrophe** — that's why the `<a>` quote class is built with `chr(39)` instead of written inline.
|
|
||||||
|
|
||||||
## LinkedIn session token
|
## LinkedIn session token
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
# linkme
|
# linkme
|
||||||
|
|
||||||
A personal job-hunting pipeline for the Swiss market. It scrapes LinkedIn (plus a handful of Swiss boards), filters out everything that doesn't fit the profile in `cv/polo.md`, appends what survives to a CSV, and pushes the good ones to Telegram twice a day.
|
A personal job-hunting pipeline for the Swiss market. It scrapes LinkedIn (plus a handful of Swiss boards), filters out everything that doesn't fit the profile in `cv/polo.md`, appends what survives to a CSV, and pushes the good ones to Telegram throughout the day.
|
||||||
|
|
||||||
Two things run on their own once installed:
|
One thing runs on its own once installed:
|
||||||
|
|
||||||
| automation | when | what it does |
|
| automation | when | what it does |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **daily digest** | 08:00 + 18:00 | fetch every search, push new profile-matching jobs to Telegram |
|
| **daily digest** | 09:00 / 12:00 / 15:00 / 17:00 / 20:00 | fetch every search + source, push new profile-matching jobs to Telegram |
|
||||||
| **sciCORE watch** | 08:47 | alert if sciCORE's open-positions page lists a *software* role |
|
|
||||||
|
sciCORE used to have its own separate 08:47 watcher; it's now just another source swept inside `bun fetch`/`bun daily` (see Sources below).
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
@@ -40,13 +41,13 @@ Then `bun daily --dry-run` prints the digest instead of sending it, and `bun dai
|
|||||||
```
|
```
|
||||||
config.ts # keyword families, search areas, the sweep() generator
|
config.ts # keyword families, search areas, the sweep() generator
|
||||||
fetch.ts # LinkedIn scraper, HTML parser, all title/content filters
|
fetch.ts # LinkedIn scraper, HTML parser, all title/content filters
|
||||||
fetch-other.ts # the eight non-LinkedIn sources (see Sources below)
|
fetch-other.ts # the ten non-LinkedIn sources (see Sources below)
|
||||||
index.ts # orchestrator: run searches → dedupe → content-check → CSV
|
index.ts # orchestrator: run searches → dedupe → content-check → CSV
|
||||||
recheck.ts # raw-mode fetch: lists what the filters dropped
|
recheck.ts # raw-mode fetch: lists what the filters dropped
|
||||||
daily.ts # digest: fetch, match against the profile, send to Telegram
|
daily.ts # digest: fetch, match against the profile, send to Telegram
|
||||||
profile.ts # title-level scoring — decides what's worth a notification
|
profile.ts # title-level scoring — decides what's worth a notification
|
||||||
telegram.ts # Telegram delivery + --setup chat-ID discovery
|
telegram.ts # Telegram delivery + --setup chat-ID discovery
|
||||||
scripts/ # one-off searches (*-temp.ts) and scicore-watch.sh
|
scripts/ # one-off searches (*-temp.ts); scicore-watch.sh kept for history, retired
|
||||||
cv/polo.md # the profile everything is ranked against
|
cv/polo.md # the profile everything is ranked against
|
||||||
results/jobs.csv # output, one row per job
|
results/jobs.csv # output, one row per job
|
||||||
results/sent.txt # links already pushed — never sent twice
|
results/sent.txt # links already pushed — never sent twice
|
||||||
@@ -67,6 +68,10 @@ LinkedIn is swept by `config.ts`'s search families. Everything else lives in `fe
|
|||||||
| **datacareer.ch** | Basel | 8 jobs on one page, no pagination |
|
| **datacareer.ch** | Basel | 8 jobs on one page, no pagination |
|
||||||
| **stellen-basel.ch** | Basel region | WordPress REST API |
|
| **stellen-basel.ch** | Basel region | WordPress REST API |
|
||||||
| **Basel intl orgs** | Basel | BIS (via the UNjobs mirror), Swiss TPH, Basel Institute on Governance |
|
| **Basel intl orgs** | Basel | BIS (via the UNjobs mirror), Swiss TPH, Basel Institute on Governance |
|
||||||
|
| **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 |
|
||||||
|
| **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:
|
||||||
|
|
||||||
@@ -165,21 +170,16 @@ bun run scripts/luzern-temp.ts
|
|||||||
|
|
||||||
## Scheduling
|
## Scheduling
|
||||||
|
|
||||||
Both automations are launchd agents, not cron — this machine has no cron at all.
|
The digest runs as a launchd agent, not cron — this machine has no cron at all.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
launchctl list | grep -E 'linkme|scicore' # are they loaded?
|
launchctl list | grep linkme # is it loaded?
|
||||||
launchctl unload ~/Library/LaunchAgents/com.rafapolo.linkme-daily.plist # stop the digest
|
launchctl unload ~/Library/LaunchAgents/com.rafapolo.linkme-daily.plist # stop it
|
||||||
launchctl unload ~/Library/LaunchAgents/ch.polo.scicore-watch.plist # stop the watcher
|
|
||||||
```
|
```
|
||||||
|
|
||||||
If the Mac is asleep at the scheduled time, launchd runs the job once it wakes rather than skipping the day.
|
If the Mac is asleep at the scheduled time, launchd runs the job once it wakes rather than skipping the day.
|
||||||
|
|
||||||
Logs: `results/daily.log` for the digest, `results/.scicore-watch/watch.log` for the watcher. The watcher logs every run — `no change`, `change ignored`, or the diff it sent — so silence on your phone can always be told apart from a broken script. Test its alert path without waiting for the real page to change:
|
Logs: `results/daily.log`.
|
||||||
|
|
||||||
```bash
|
|
||||||
SCICORE_URL="file://$PWD/fixture.html" bash scripts/scicore-watch.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## recheck
|
## recheck
|
||||||
|
|
||||||
|
|||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
# Applied
|
||||||
|
|
||||||
|
| n | Company | Address | Link |
|
||||||
|
| --- | --------------------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 1 | Prolog | Hammerstrasse 44, 4058 Basel, Switzerland | https://prolog.work/en/ |
|
||||||
|
| 2 | PAS01 | Schützengraben 39, 4051 Basel, Switzerland | https://www.linkedin.com/jobs/view/4461764782/ |
|
||||||
|
| 3 | xorlab | Binzmühlestrasse 170d, 8050 Zürich, Switzerland | https://www.linkedin.com/jobs/view/4455873749/ |
|
||||||
|
| 4 | Scopely | Zürich, Switzerland (posting location "CH - Zurich"; exact street not publicly listed) | https://job-boards.greenhouse.io/scopely/jobs/5250466008 |
|
||||||
|
| 5 | RUAG | Seetalstrasse 175, 6032 Emmen, Switzerland | https://jobs.ruag.ch/offene-stellen/senior-software-engineer-c5i/4ed6e938-293d-4f9a-9d22-627bab9bb5ae |
|
||||||
|
| 6 | World Economic Forum | Route de la Capite 91-93, 1223 Cologny/Geneva, Switzerland | https://weforum.wd3.myworkdayjobs.com/en-US/Forum_Careers/job/Practice-Engineer---API_R4247 |
|
||||||
|
| 7 | Swiss Re | Mythenquai 50/60, 8002 Zürich, Switzerland | https://careers.swissre.com/job/Zurich-Cyber-Security-Analyst-%28fmxd%2C-hybrid-set-up%29-Zuri/1429246733/ |
|
||||||
|
| 8 | Helvetia | Dufourstrasse 40, 9001 St. Gallen, Switzerland | https://jobs.helvetia.com/offene-stellen/web-security-operations-platform-engineer-f-m-d-part-time-full-time/aa825fe3-630d-4570-bcb0-e42fbca0ad37 |
|
||||||
|
| 9 | Profidata | Bändliweg 30, 8048 Zürich, Switzerland | https://jobs.profidata.com/jobs/8150279-react-frontend-engineer-80-100-f-m-d |
|
||||||
|
| 10 | Siemens | Theilerstrasse 1a, 6300 Zug, Switzerland (Siemens Schweiz AG HQ — posting itself has no fixed location) | https://jobs.siemens.com/de_DE/externaljobs/JobDetail/519997 |
|
||||||
|
| 11 | MDPI | St. Alban-Anlage 66, 4052 Basel, Switzerland | https://www.linkedin.com/jobs/view/4428110943/ |
|
||||||
|
| 12 | DarkNetSearch (Kaduu AG) | Chamerstrasse 44, 6300 Zug, Switzerland | https://darknetsearch.com/contact |
|
||||||
|
| 13 | Cybergence | Basel, Switzerland (also has Stuttgart/DE, Timisoara/RO, Wrocław/PL offices; exact street not publicly listed) | cybergence.io |
|
||||||
|
| 14 | Galenica | Untermattweg 8, 3027 Bern, Switzerland | https://jobs.galenica.com/en/jobs/7728en-us |
|
||||||
|
| 15 | MDPI | St. Alban-Anlage 66, 4052 Basel, Switzerland | https://careers.ch.mdpi.com/jobs/7902315-data-scientist-100/applications/new |
|
||||||
|
| 16 | Cisco (Isovalent) | Richtistrasse 7, 8304 Wallisellen (Zürich), Switzerland | https://careers.cisco.com/global/en/job/2012314/Tetragon-Threat-Detection-Engineer-Isovalent |
|
||||||
|
| 17 | Galaxus | Pfingstweidstrasse 60b, 8005 Zürich, Switzerland | https://www.galaxus.ch/en/joboffer/4376 |
|
||||||
|
| 18 | Swissgrid | Bleichemattstrasse 31, 5001 Aarau, Switzerland | https://jobs.swissgrid.ch/job/Aarau-Cyber-Security-Analyst-%2880-100%29-AG-5001/1367610257/ |
|
||||||
|
| 19 | Swiss-AS (Swiss AviationSoftware) | Rudolf Geigy-Strasse 3, 4123 Allschwil, Switzerland | https://swissas.teamtailor.com/jobs/8339670-full-stack-all-round-software-engineer-for-in-house-tools-development |
|
||||||
|
| 20 | Partners Group | Zugerstrasse 57, 6341 Baar, Switzerland | https://jobs.partnersgroup.com/job/Baar-AI-Engineer/1411882033/ |
|
||||||
|
| 21 | Profidata | Bändliweg 30, 8048 Zürich, Switzerland | https://jobs.profidata.com/jobs/8150279-react-frontend-engineer-80-100-f-m-d |
|
||||||
|
| 22 | Swisscom | Alte Tiefenaustrasse 6, 3050 Bern, Switzerland | https://swisscom.wd103.myworkdayjobs.com/de-DE/SwisscomExternalCareers/job/Bern/Senior-Business-Engineer---People-Analytics_R-0005728-1 |
|
||||||
|
| 23 | AMPD Labs | Poststrasse 24, 6300 Zug, Switzerland | https://www.linkedin.com/jobs/view/4459415360/ |
|
||||||
|
| 24 | Adobe | Peter-Merian-Strasse 80, 4052 Basel, Switzerland | https://careers.adobe.com/us/en/job/R170666/Senior-Software-Engineer-AEM-Assets |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
add on job room
|
||||||
|
|
||||||
|
| 20 | Partners Group | Zugerstrasse 57, 6341 Baar, Switzerland | https://jobs.partnersgroup.com/job/Baar-AI-Engineer/1411882033/ |
|
||||||
|
| 21 | Profidata | Bändliweg 30, 8048 Zürich, Switzerland | https://jobs.profidata.com/jobs/8150279-react-frontend-engineer-80-100-f-m-d |
|
||||||
|
| 22 | Swisscom | Alte Tiefenaustrasse 6, 3050 Bern, Switzerland | https://swisscom.wd103.myworkdayjobs.com/de-DE/SwisscomExternalCareers/job/Bern/Senior-Business-Engineer---People-Analytics_R-0005728-1 |
|
||||||
|
| 23 | AMPD Labs | Poststrasse 24, 6300 Zug, Switzerland | https://www.linkedin.com/jobs/view/4459415360/ |
|
||||||
|
| 24 | Adobe | Peter-Merian-Strasse 80, 4052 Basel, Switzerland | https://careers.adobe.com/us/en/job/R170666/Senior-Software-Engineer-AEM-Assets |
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 2,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "linkme",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "^1.63.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ export const KEYWORDS = [
|
|||||||
"Backend Engineer",
|
"Backend Engineer",
|
||||||
"Data Engineer",
|
"Data Engineer",
|
||||||
"AI Engineer",
|
"AI Engineer",
|
||||||
|
"Agentic",
|
||||||
"Applications Engineer",
|
"Applications Engineer",
|
||||||
"Platform Engineer",
|
"Platform Engineer",
|
||||||
"DevOps Engineer",
|
"DevOps Engineer",
|
||||||
@@ -84,6 +85,7 @@ export const RESEARCH_KEYWORDS = [
|
|||||||
/** Customer-facing and early-stage product roles — the consulting lane. */
|
/** Customer-facing and early-stage product roles — the consulting lane. */
|
||||||
export const FIELD_KEYWORDS = [
|
export const FIELD_KEYWORDS = [
|
||||||
"Forward Deployed Engineer",
|
"Forward Deployed Engineer",
|
||||||
|
"Palantir Foundry",
|
||||||
"Field Engineer",
|
"Field Engineer",
|
||||||
"Customer Engineer",
|
"Customer Engineer",
|
||||||
"Sales Engineer",
|
"Sales Engineer",
|
||||||
@@ -168,8 +170,35 @@ export const searches: SearchConfig[] = [
|
|||||||
workType: ["onsite", "hybrid"],
|
workType: ["onsite", "hybrid"],
|
||||||
timePosted: "month",
|
timePosted: "month",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Core KEYWORDS previously only ran onsite/hybrid against the three named
|
||||||
|
// cities — a manual "Fullstack Developer" remote-CH search on 2026-09-10
|
||||||
|
// turned up 21 viable postings (PAS01, xorlab, Proton, SCOR...) that no
|
||||||
|
// existing search covered, since Platform/Field are the only families with
|
||||||
|
// a Switzerland-wide remote sweep. Adding the same sweep for core KEYWORDS.
|
||||||
|
label: "Switzerland Remote",
|
||||||
|
keywords: KEYWORDS,
|
||||||
|
location: "Switzerland",
|
||||||
|
workType: ["remote"],
|
||||||
|
timePosted: "month",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Frontend, web and data-visualisation / geospatial — the Cantography lane. */
|
||||||
|
export const FRONTEND_KEYWORDS = [
|
||||||
|
"Frontend Engineer",
|
||||||
|
"Front-end Developer",
|
||||||
|
"React",
|
||||||
|
"React Developer",
|
||||||
|
"Full-Stack Developer",
|
||||||
|
"Web Developer",
|
||||||
|
"Data Visualization",
|
||||||
|
"Geospatial",
|
||||||
|
"GIS Developer",
|
||||||
|
"WebGL",
|
||||||
|
"Creative Technologist",
|
||||||
|
].join(" OR ");
|
||||||
|
|
||||||
// --- Role-family sweeps ---------------------------------------------------
|
// --- Role-family sweeps ---------------------------------------------------
|
||||||
// Same filters as the core searches, run across a narrower set of areas.
|
// Same filters as the core searches, run across a narrower set of areas.
|
||||||
// Research is Basel/Zürich only (that's where the institutes are); platform
|
// Research is Basel/Zürich only (that's where the institutes are); platform
|
||||||
@@ -212,5 +241,6 @@ function sweep(
|
|||||||
searches.push(
|
searches.push(
|
||||||
...sweep("Platform", PLATFORM_KEYWORDS, CORE_AREAS, { remote: true }),
|
...sweep("Platform", PLATFORM_KEYWORDS, CORE_AREAS, { remote: true }),
|
||||||
...sweep("Research", RESEARCH_KEYWORDS, RESEARCH_AREAS),
|
...sweep("Research", RESEARCH_KEYWORDS, RESEARCH_AREAS),
|
||||||
...sweep("Field", FIELD_KEYWORDS, CORE_AREAS, { remote: true })
|
...sweep("Field", FIELD_KEYWORDS, CORE_AREAS, { remote: true }),
|
||||||
|
...sweep("Frontend", FRONTEND_KEYWORDS, CORE_AREAS, { remote: true })
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Parse one jobs.csv line. The file holds both fully-quoted rows (what
|
||||||
|
// index.ts writes) and minimally-quoted ones (any tool that rewrites the CSV,
|
||||||
|
// e.g. Python's csv module, only quotes fields containing a comma). Splitting
|
||||||
|
// on '","' only understands the first form — every minimally-quoted row came
|
||||||
|
// back with no link, so index.ts re-added old jobs as new and daily.ts
|
||||||
|
// couldn't dedupe against them.
|
||||||
|
export function parseCsvLine(line: string): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
let cur = "";
|
||||||
|
let quoted = false;
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const ch = line[i];
|
||||||
|
if (quoted) {
|
||||||
|
if (ch === '"' && line[i + 1] === '"') { cur += '"'; i++; }
|
||||||
|
else if (ch === '"') quoted = false;
|
||||||
|
else cur += ch;
|
||||||
|
} else if (ch === '"') quoted = true;
|
||||||
|
else if (ch === ",") { out.push(cur); cur = ""; }
|
||||||
|
else cur += ch;
|
||||||
|
}
|
||||||
|
out.push(cur);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Rafael Polo - CV</title>
|
|
||||||
<style>
|
|
||||||
@page {
|
|
||||||
size: 8.2681in 11.6929in;
|
|
||||||
margin: 0.5in;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
font-family: Calibri, Liberation Sans, sans-serif;
|
|
||||||
font-size: 11pt;
|
|
||||||
line-height: 1.2;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.container {
|
|
||||||
display: table;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.left-col {
|
|
||||||
display: table-cell;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.title {
|
|
||||||
font-size: 24pt;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 0.05in;
|
|
||||||
}
|
|
||||||
.subtitle {
|
|
||||||
font-size: 11pt;
|
|
||||||
font-style: italic;
|
|
||||||
margin-bottom: 0.15in;
|
|
||||||
color: #444;
|
|
||||||
}
|
|
||||||
table.personal {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin-bottom: 0.2in;
|
|
||||||
}
|
|
||||||
table.personal td {
|
|
||||||
padding: 0.02in 0.05in;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
table.personal .label {
|
|
||||||
font-weight: bold;
|
|
||||||
color: #333;
|
|
||||||
width: 1in;
|
|
||||||
}
|
|
||||||
table.personal .header-row td {
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 11pt;
|
|
||||||
text-transform: uppercase;
|
|
||||||
border-bottom: 1px solid #999;
|
|
||||||
padding-bottom: 0.05in;
|
|
||||||
}
|
|
||||||
h3 {
|
|
||||||
font-size: 11pt;
|
|
||||||
font-weight: bold;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
margin: 0.15in 0 0.08in 0;
|
|
||||||
border-bottom: 1px solid #999;
|
|
||||||
padding-bottom: 0.03in;
|
|
||||||
}
|
|
||||||
ul {
|
|
||||||
margin: 0.05in 0;
|
|
||||||
padding-left: 0.15in;
|
|
||||||
}
|
|
||||||
li {
|
|
||||||
margin-bottom: 0.04in;
|
|
||||||
font-size: 10pt;
|
|
||||||
}
|
|
||||||
.job-item {
|
|
||||||
margin-bottom: 0.08in;
|
|
||||||
}
|
|
||||||
.job-title {
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 10pt;
|
|
||||||
}
|
|
||||||
.job-meta {
|
|
||||||
font-style: italic;
|
|
||||||
font-size: 9pt;
|
|
||||||
color: #555;
|
|
||||||
}
|
|
||||||
.job-desc {
|
|
||||||
font-style: italic;
|
|
||||||
font-size: 10pt;
|
|
||||||
color: #444;
|
|
||||||
margin-left: 0.1in;
|
|
||||||
}
|
|
||||||
.edu-item {
|
|
||||||
margin-bottom: 0.05in;
|
|
||||||
}
|
|
||||||
.edu-degree {
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 10pt;
|
|
||||||
}
|
|
||||||
.edu-school {
|
|
||||||
font-style: italic;
|
|
||||||
font-size: 10pt;
|
|
||||||
}
|
|
||||||
.skill-item {
|
|
||||||
font-size: 10pt;
|
|
||||||
margin-bottom: 0.02in;
|
|
||||||
}
|
|
||||||
.lang-item {
|
|
||||||
font-size: 10pt;
|
|
||||||
margin-bottom: 0.02in;
|
|
||||||
}
|
|
||||||
.footer {
|
|
||||||
font-size: 9pt;
|
|
||||||
color: #666;
|
|
||||||
margin-top: 0.2in;
|
|
||||||
padding-top: 0.1in;
|
|
||||||
border-top: 1px solid #ccc;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="title">Rafael Polo</div>
|
|
||||||
<div class="subtitle">Senior Software Engineer — Platform, Data & AI</div>
|
|
||||||
|
|
||||||
<table class="personal">
|
|
||||||
<tr>
|
|
||||||
<td colspan="2" class="header-row">Personal</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="label">First name:</td>
|
|
||||||
<td>Rafael</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="label">Last name:</td>
|
|
||||||
<td>Polo</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="label">Citizenship:</td>
|
|
||||||
<td>Portuguese (EU) · Brazilian — no work permit required</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="label">Address:</td>
|
|
||||||
<td>Basel, 4055, Switzerland</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="label">Email:</td>
|
|
||||||
<td>rafael@extrapolo.com</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="label">Web:</td>
|
|
||||||
<td>linkedin.com/in/rafaelpolo<br>extrapolo.com</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h3>Formal Education</h3>
|
|
||||||
<ul>
|
|
||||||
<li class="edu-item">
|
|
||||||
<span class="edu-degree"><em><strong>Postgraduate in Data Science for Finance</strong></em> – 2021</span><br>
|
|
||||||
<span class="edu-school">NOVA IMS Information Management School, Lisbon</span>
|
|
||||||
</li>
|
|
||||||
<li class="edu-item">
|
|
||||||
<span class="edu-degree"><em><strong>Master in Information Systems</strong></em> – 2010–2012</span><br>
|
|
||||||
<span class="edu-school">PPGI/UNIRIO — Knowledge Representation & Reasoning</span>
|
|
||||||
</li>
|
|
||||||
<li class="edu-item">
|
|
||||||
<span class="edu-degree"><em><strong>Bachelor in Computer Science</strong></em> – 2004–2009</span><br>
|
|
||||||
<span class="edu-school">Metodista Bennett, Rio de Janeiro</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>Relevant Professional Experience</h3>
|
|
||||||
<ul>
|
|
||||||
<li class="job-item">
|
|
||||||
<span class="job-title">Swisscom</span> – <span class="job-meta">2024–2025 – Zürich, Switzerland</span><br>
|
|
||||||
<span class="job-desc"><strong>DevOps Engineer</strong> developing platforms for Telecommunication Operations.</span>
|
|
||||||
</li>
|
|
||||||
<li class="job-item">
|
|
||||||
<span class="job-title">EDGE Empower®</span> – <span class="job-meta">2024–2025 – Baar, Switzerland</span><br>
|
|
||||||
<span class="job-desc"><strong>Senior Technical Lead</strong> managing projects and external UX & Dev Teams for a SaaS Product.</span>
|
|
||||||
</li>
|
|
||||||
<li class="job-item">
|
|
||||||
<span class="job-title">ExtraPolo — Independent Consulting</span> – <span class="job-meta">2010–2022 – Rio de Janeiro · Berlin · Athens · Lisbon</span><br>
|
|
||||||
<span class="job-desc">40+ projects across civic tech, data journalism, open-source platforms and interactive installations for public and private clients.</span>
|
|
||||||
</li>
|
|
||||||
<li class="job-item">
|
|
||||||
<span class="job-title">Moedax Digital Services</span> – <span class="job-meta">2018–2021 – Lisbon · Rio de Janeiro</span><br>
|
|
||||||
<span class="job-desc"><strong>Founder & CTO</strong> – Fintech startup connecting CBDC digital currency with KYC compliance automation.</span>
|
|
||||||
</li>
|
|
||||||
<li class="job-item">
|
|
||||||
<span class="job-title">GFOSS.eu / Code + Create</span> – <span class="job-meta">2017–2018 – Athens, Greece</span><br>
|
|
||||||
<span class="job-desc"><strong>Web Programming Teacher</strong> – taught HTML, CSS, jQuery to refugee and local youth.</span>
|
|
||||||
</li>
|
|
||||||
<li class="job-item">
|
|
||||||
<span class="job-title">Tactical Tech Collective</span> – <span class="job-meta">2014–2016 – Berlin, Germany</span><br>
|
|
||||||
<span class="job-desc"><strong>Full-Stack Web Architect</strong> – investigative data tools under Exposing the Invisible team.</span>
|
|
||||||
</li>
|
|
||||||
<li class="job-item">
|
|
||||||
<span class="job-title">Trommsdorf + Drüner</span> – <span class="job-meta">2013–2014 – Berlin, Germany</span><br>
|
|
||||||
<span class="job-desc"><strong>Full-Stack Engineer</strong> – Innovation Team developing Business Intelligence dashboards.</span>
|
|
||||||
</li>
|
|
||||||
<li class="job-item">
|
|
||||||
<span class="job-title">Cortex Intelligence</span> – <span class="job-meta">2007–2008 – Rio de Janeiro, Brazil</span><br>
|
|
||||||
<span class="job-desc"><strong>Developer</strong> – Competitive Intelligence and Data Mining R&D team.</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>Certifications</h3>
|
|
||||||
<ul>
|
|
||||||
<li class="skill-item">Swiss Federal Act on Data Protection (nFADP)</li>
|
|
||||||
<li class="skill-item">Gen AI Foundational Models — NLP & LLMs</li>
|
|
||||||
<li class="skill-item">AI Agents using RAG and LangChain</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>Relevant Tech Skills</h3>
|
|
||||||
<ul>
|
|
||||||
<li class="skill-item"><strong>Languages & Frameworks:</strong> Python · Ruby on Rails · TypeScript · React · Java</li>
|
|
||||||
<li class="skill-item"><strong>DevOps & Infra:</strong> Docker · Kubernetes · CI/CD · AWS · Linux · Git</li>
|
|
||||||
<li class="skill-item"><strong>Data & AI:</strong> SQL · ETL · Data Engineering · RAG · LangChain · LLM integrations</li>
|
|
||||||
<li class="skill-item"><strong>Architecture:</strong> REST APIs · MVC · OOP · UML · System Design · Web Crawling</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>Natural Languages</h3>
|
|
||||||
<ul>
|
|
||||||
<li class="lang-item">Native Brazilian Portuguese</li>
|
|
||||||
<li class="lang-item">Full professional English</li>
|
|
||||||
<li class="lang-item">Full professional Spanish</li>
|
|
||||||
<li class="lang-item">German B1 – TELC certified · 3 yrs Berlin + 3 yrs Basel</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div class="footer">
|
|
||||||
References available upon request · CV updated May 2026
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Rafael Polo - CV</title>
|
|
||||||
<style>
|
|
||||||
@page {
|
|
||||||
size: 8.2681in 11.6929in;
|
|
||||||
margin: 0.5in;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
font-family: Liberation Sans, Carlito, Georgia, serif;
|
|
||||||
font-size: 11pt;
|
|
||||||
line-height: 1.4;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.title {
|
|
||||||
font-size: 36pt;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 0.1in;
|
|
||||||
}
|
|
||||||
.subtitle {
|
|
||||||
font-size: 14pt;
|
|
||||||
margin-bottom: 0.3in;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
font-size: 24pt;
|
|
||||||
font-weight: bold;
|
|
||||||
margin: 0.2in 0 0.1in 0;
|
|
||||||
border-bottom: 1px solid #333;
|
|
||||||
padding-bottom: 0.05in;
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
font-size: 18pt;
|
|
||||||
font-weight: bold;
|
|
||||||
margin: 0.15in 0 0.08in 0;
|
|
||||||
}
|
|
||||||
h3 {
|
|
||||||
font-size: 14pt;
|
|
||||||
font-weight: bold;
|
|
||||||
margin: 0.1in 0 0.05in 0;
|
|
||||||
}
|
|
||||||
.section {
|
|
||||||
margin-bottom: 0.15in;
|
|
||||||
}
|
|
||||||
.personalinline {
|
|
||||||
font-size: 10pt;
|
|
||||||
margin-bottom: 0.2in;
|
|
||||||
}
|
|
||||||
.personalinline span {
|
|
||||||
margin-right: 0.3in;
|
|
||||||
}
|
|
||||||
.key-value {
|
|
||||||
display: flex;
|
|
||||||
margin: 0.03in 0;
|
|
||||||
}
|
|
||||||
.key {
|
|
||||||
font-weight: bold;
|
|
||||||
width: 1.5in;
|
|
||||||
}
|
|
||||||
.value {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
ul {
|
|
||||||
margin: 0.05in 0;
|
|
||||||
padding-left: 0.2in;
|
|
||||||
}
|
|
||||||
li {
|
|
||||||
margin-bottom: 0.03in;
|
|
||||||
}
|
|
||||||
.small {
|
|
||||||
font-size: 10pt;
|
|
||||||
}
|
|
||||||
.experience-item {
|
|
||||||
margin-bottom: 0.1in;
|
|
||||||
}
|
|
||||||
.exp-header {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.exp-meta {
|
|
||||||
font-style: italic;
|
|
||||||
font-size: 10pt;
|
|
||||||
}
|
|
||||||
.exp-desc {
|
|
||||||
margin-left: 0.2in;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="title">Rafael Polo</div>
|
|
||||||
<div class="subtitle">Senior Software Engineer — Platform, Data & AI</div>
|
|
||||||
|
|
||||||
<div class="personalinline">
|
|
||||||
<span>Basel, 4055, Switzerland</span>
|
|
||||||
<span>rafael@extrapolo.com</span>
|
|
||||||
<span>linkedin.com/in/rafaelpolo</span>
|
|
||||||
<span>extrapolo.com</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="personalinline">
|
|
||||||
<span>Portuguese (EU) · Brazilian — no work permit required</span>
|
|
||||||
<span>20+ years building production systems across Switzerland, Germany, Greece and Brazil</span>
|
|
||||||
<span>Fluent English · German B1 TELC (3 yrs Berlin + 3 yrs Basel) · EU citizen, immediately available</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h1>P R O F E S S I O N A L E X P E R I E N C E</h1>
|
|
||||||
|
|
||||||
<div class="experience-item">
|
|
||||||
<div class="exp-header">Swisscom</div>
|
|
||||||
<div class="exp-meta">DevOps Engineer · Zürich, Switzerland</div>
|
|
||||||
<div class="exp-desc">Modernization of mission-critical internal platforms & CI/CD pipelines for high-availability Telecom infrastructure.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="experience-item">
|
|
||||||
<div class="exp-header">EDGE Empower®</div>
|
|
||||||
<div class="exp-meta">Senior Technical Lead · Baar, Switzerland · 2024 – 2025</div>
|
|
||||||
<div class="exp-desc">Built a PayTool cutting processing time by 98%; reduced CI/CD pipeline from 14 → 4 minutes; managed 3 developers and a UX designer for a SaaS product.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="experience-item">
|
|
||||||
<div class="exp-header">ExtraPolo — Independent Consulting</div>
|
|
||||||
<div class="exp-meta">Software Engineer & Consultant · Rio de Janeiro · Berlin · Athens · Lisbon · 2010 – 2022</div>
|
|
||||||
<div class="exp-desc">40+ projects across civic tech, data journalism, open-source platforms and interactive installations for public and private clients.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="experience-item">
|
|
||||||
<div class="exp-header">Moedax Digital Services</div>
|
|
||||||
<div class="exp-meta">Founder & CTO · Lisbon · Rio de Janeiro · 2018 – 2021</div>
|
|
||||||
<div class="exp-desc">Fintech startup connecting CBDC digital currency with KYC compliance automation; built data pipelines and banking integrations in Python.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="experience-item">
|
|
||||||
<div class="exp-header">GFOSS.eu / Code + Create</div>
|
|
||||||
<div class="exp-meta">Web Programming Teacher · Athens, Greece · 2017 – 2018</div>
|
|
||||||
<div class="exp-desc">Taught web programming (HTML, CSS, jQuery) to refugee and local youth through the EU-funded Code + Create initiative.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="experience-item">
|
|
||||||
<div class="exp-header">Tactical Tech Collective</div>
|
|
||||||
<div class="exp-meta">Full-Stack Web Architect · Berlin, Germany · Dec 2014 – Jun 2016</div>
|
|
||||||
<div class="exp-desc">Built investigative data tools and platforms under the Exposing the Invisible team (Ruby on Rails, data visualisation).</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="experience-item">
|
|
||||||
<div class="exp-header">Trommsdorff + Drüner</div>
|
|
||||||
<div class="exp-meta">Full-Stack Engineer · Berlin, Germany</div>
|
|
||||||
<div class="exp-desc">Innovation team — Business Intelligence dashboards for marketing consultancy clients.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="experience-item">
|
|
||||||
<div class="exp-header">Cortex Intelligence</div>
|
|
||||||
<div class="exp-meta">Developer · Rio de Janeiro, Brazil</div>
|
|
||||||
<div class="exp-desc">Competitive Intelligence and Data Mining R&D team.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h1>E D U C A T I O N</h1>
|
|
||||||
<div class="key-value"><span class="key">Postgraduate in Data Science for Finance</span><span class="value">NOVA IMS Information Management School, Lisbon — 2021</span></div>
|
|
||||||
<div class="key-value"><span class="key">Master in Information Systems</span><span class="value">PPGI/UNIRIO — Knowledge Representation & Reasoning — 2010–2012</span></div>
|
|
||||||
<div class="key-value"><span class="key">Bachelor in Computer Science</span><span class="value">Metodista Bennett, Rio de Janeiro — 2004–2009</span></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h1>L A N G U A G E S</h1>
|
|
||||||
<div class="key-value"><span class="key">Portuguese</span><span class="value">native</span></div>
|
|
||||||
<div class="key-value"><span class="key">English</span><span class="value">full professional</span></div>
|
|
||||||
<div class="key-value"><span class="key">Spanish</span><span class="value">full professional</span></div>
|
|
||||||
<div class="key-value"><span class="key">German</span><span class="value">B1 — TELC certified · 3 yrs Berlin + 3 yrs Basel</span></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h1>C E R T I F I C A T I O N S</h1>
|
|
||||||
<ul>
|
|
||||||
<li>Swiss Federal Act on Data Protection (nFADP)</li>
|
|
||||||
<li>Gen AI Foundational Models — NLP & LLMs</li>
|
|
||||||
<li>AI Agents using RAG and LangChain</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h1>T E C H S K I L L S</h1>
|
|
||||||
<div class="key-value"><span class="key">Languages & Frameworks</span><span class="value">Python · Ruby on Rails · TypeScript · React · Java</span></div>
|
|
||||||
<div class="key-value"><span class="key">DevOps & Infra</span><span class="value">Docker · Kubernetes · CI/CD · AWS · Linux · Git</span></div>
|
|
||||||
<div class="key-value"><span class="key">Data & AI</span><span class="value">SQL · ETL · Data Engineering · RAG · LangChain · LLM integrations · Data Science</span></div>
|
|
||||||
<div class="key-value"><span class="key">Architecture</span><span class="value">REST APIs · MVC · OOP · UML · System Design · Web Crawling & Scraping</span></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="small">
|
|
||||||
References available upon request · CV updated May 2026
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Rafael Polo - CV</title>
|
|
||||||
<style>
|
|
||||||
@page {
|
|
||||||
size: 8.2681in 11.6929in;
|
|
||||||
margin: 0.5in;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
font-family: Liberation Sans, Carlito, Calibri, Georgia, sans-serif;
|
|
||||||
font-size: 11pt;
|
|
||||||
line-height: 1.3;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.container {
|
|
||||||
display: table;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.left-col {
|
|
||||||
display: table-cell;
|
|
||||||
width: 65%;
|
|
||||||
padding-right: 0.2in;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
.right-col {
|
|
||||||
display: table-cell;
|
|
||||||
width: 35%;
|
|
||||||
vertical-align: top;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
.photo {
|
|
||||||
width: 2in;
|
|
||||||
height: auto;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
}
|
|
||||||
.title {
|
|
||||||
font-size: 24pt;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 0.05in;
|
|
||||||
}
|
|
||||||
.subtitle {
|
|
||||||
font-size: 12pt;
|
|
||||||
font-style: italic;
|
|
||||||
margin-bottom: 0.2in;
|
|
||||||
color: #444;
|
|
||||||
}
|
|
||||||
h3 {
|
|
||||||
font-size: 12pt;
|
|
||||||
font-weight: bold;
|
|
||||||
margin: 0.15in 0 0.08in 0;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05in;
|
|
||||||
border-bottom: 1px solid #999;
|
|
||||||
padding-bottom: 0.03in;
|
|
||||||
}
|
|
||||||
.personal-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
.personal-table td {
|
|
||||||
padding: 0.02in 0;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
.personal-table .label {
|
|
||||||
font-weight: bold;
|
|
||||||
width: 1.2in;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
.personal-table .header {
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 12pt;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05in;
|
|
||||||
padding-bottom: 0.05in;
|
|
||||||
}
|
|
||||||
.section-header {
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 11pt;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.03in;
|
|
||||||
margin-top: 0.15in;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
ul {
|
|
||||||
margin: 0.05in 0;
|
|
||||||
padding-left: 0.15in;
|
|
||||||
}
|
|
||||||
li {
|
|
||||||
margin-bottom: 0.04in;
|
|
||||||
font-size: 10pt;
|
|
||||||
}
|
|
||||||
.job-title {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.job-meta {
|
|
||||||
font-style: italic;
|
|
||||||
font-size: 10pt;
|
|
||||||
}
|
|
||||||
.job-company {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.job-desc {
|
|
||||||
font-style: italic;
|
|
||||||
font-size: 10pt;
|
|
||||||
margin-left: 0.1in;
|
|
||||||
}
|
|
||||||
.edu-item {
|
|
||||||
font-size: 10pt;
|
|
||||||
margin-bottom: 0.03in;
|
|
||||||
}
|
|
||||||
.edu-school {
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
.skill-category {
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 10pt;
|
|
||||||
margin-top: 0.05in;
|
|
||||||
}
|
|
||||||
.skill-list {
|
|
||||||
font-size: 10pt;
|
|
||||||
margin-left: 0.1in;
|
|
||||||
}
|
|
||||||
.footer {
|
|
||||||
font-size: 9pt;
|
|
||||||
color: #666;
|
|
||||||
margin-top: 0.3in;
|
|
||||||
border-top: 1px solid #ccc;
|
|
||||||
padding-top: 0.1in;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="left-col">
|
|
||||||
<p class="title">Rafael Polo</p>
|
|
||||||
<p class="subtitle">Senior Software Engineer — Platform, Data & AI</p>
|
|
||||||
|
|
||||||
<table class="personal-table">
|
|
||||||
<tr>
|
|
||||||
<td class="header" colspan="2">Personal</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="label">Address:</td>
|
|
||||||
<td>Basel, 4055, Switzerland</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="label">Email:</td>
|
|
||||||
<td>rafael@extrapolo.com</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="label">LinkedIn:</td>
|
|
||||||
<td>linkedin.com/in/rafaelpolo</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="label">Web:</td>
|
|
||||||
<td>extrapolo.com</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="label">Citizenship:</td>
|
|
||||||
<td>Portuguese (EU) · Brazilian — no work permit required</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="label">Languages:</td>
|
|
||||||
<td>Fluent English · German B1 TELC (3 yrs Berlin + 3 yrs Basel) · EU citizen, immediately available</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h3>Formal Education</h3>
|
|
||||||
<ul>
|
|
||||||
<li><em><strong>Postgraduate in Data Science for Finance</strong></em> – 2021<br>
|
|
||||||
<span class="edu-school">NOVA IMS Information Management School, Lisbon</span></li>
|
|
||||||
<li><em><strong>Master in Information Systems</strong></em> – 2010–2012<br>
|
|
||||||
<span class="edu-school">Research Area: Knowledge Representation & Reasoning, PPGI/UNIRIO</span></li>
|
|
||||||
<li><em><strong>Bachelor in Computer Science</strong></em> – 2004–2009<br>
|
|
||||||
<span class="edu-school">Metodista Bennett, Rio de Janeiro</span></li>
|
|
||||||
<li>Thesis: Agile Systems Development for Web 2.0 with Ruby on Rails</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>Relevant Professional Experience</h3>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Swisscom</strong> – <em>2024–2025</em> – <em>Zürich, Switzerland</em>
|
|
||||||
<p class="job-desc"><strong>DevOps Engineer</strong> developing platforms for Telecommunication Operations.</p></li>
|
|
||||||
<li><strong>EDGE Empower®</strong> – <em>2024–2025</em> – <em>Baar, Switzerland</em>
|
|
||||||
<p class="job-desc"><strong>Senior Technical Lead</strong> managing projects and external UX & Dev Teams for a SaaS Company.</p></li>
|
|
||||||
<li><strong>ExtraPolo — Independent Consulting</strong> – <em>2010–2022</em> – <em>Rio de Janeiro · Berlin · Athens · Lisbon</em>
|
|
||||||
<p class="job-desc">For more than 10 years, worked as an independent Software Developer and IT Consultant, developing dozens of Projects for public and private contractors in Brazil, Germany, Greece and Switzerland.</p></li>
|
|
||||||
<li><strong>Moedax Digital Services</strong> – <em>2018–2021</em> – <em>Lisbon and Rio de Janeiro</em>
|
|
||||||
<p class="job-desc"><strong>Founder & CTO</strong> – connected Brazilian CBDC with KYC processes and automation</p></li>
|
|
||||||
<li><strong>GFOSS.eu / Code + Create</strong> – <em>2017–2018</em> – <em>Athens, Greece</em>
|
|
||||||
<p class="job-desc"><strong>Web Programming Teacher</strong> – taught web programming to refugee and local youth</p></li>
|
|
||||||
<li><strong>Tactical Tech Collective</strong> – <em>2014–2016</em> – <em>Berlin, Germany</em>
|
|
||||||
<p class="job-desc"><strong>Full-Stack Web Architect</strong> on an NGO under the Exposing the Invisible Team.</p></li>
|
|
||||||
<li><strong>Trommsdorf + Drüner</strong> – <em>2013–2014</em> – <em>Berlin, Germany</em>
|
|
||||||
<p class="job-desc"><strong>Full-stack Engineer</strong> – Innovation Team developing Business Intelligence dashboards.</p></li>
|
|
||||||
<li><strong>Cortex Intelligence</strong> – <em>2007–2008</em> – <em>Rio de Janeiro, Brazil</em>
|
|
||||||
<p class="job-desc"><strong>Developer</strong> in the Competitive Intelligence and Data Mining Team.</p></li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>Certifications</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Swiss Federal Act on Data Protection (nFADP)</li>
|
|
||||||
<li>Gen AI Foundational Models — NLP & LLMs</li>
|
|
||||||
<li>AI Agents using RAG and LangChain</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>Relevant Tech Skills</h3>
|
|
||||||
<ul>
|
|
||||||
<li>System Analysis & Modeling with UML</li>
|
|
||||||
<li>Quick Proof-of-Concepts development, deploy and management.</li>
|
|
||||||
<li>Large Language Models, Gen AI, LangChain integration and Processes Automation</li>
|
|
||||||
<li>MVC System Architecture, Design Patterns & Object-Oriented Programming</li>
|
|
||||||
<li>Data Engineering, Data Science, Relational Databases & SQL</li>
|
|
||||||
<li>Experience with Blockchain, Smart Contracts and P2P systems</li>
|
|
||||||
<li>Distributing and Scaling Processes with Message Queues</li>
|
|
||||||
<li>Developing Web Crawlers & Bots for Data Mining & ETL</li>
|
|
||||||
<li>Linux Systems, VPS, DevOps, SSH & Deploy with Continuous Integration processes</li>
|
|
||||||
<li>Projects mostly developed in Python, Ruby on Rails, Django, Java</li>
|
|
||||||
<li>Full Stack Developer & Front-end Web Design with HTML, CSS and JavaScript frameworks</li>
|
|
||||||
<li>CI/CD, Docker, Kubernetes and GitHub workflows</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>Natural Languages</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Native Brazilian Portuguese</li>
|
|
||||||
<li>Advanced Spoken Spanish</li>
|
|
||||||
<li>A2 level Greek – <em>3 years in Athens</em></li>
|
|
||||||
<li>B1 level German / TELC proof – <em>3 years in Berlin</em> and 3 in Switzerland</li>
|
|
||||||
<li>Fluent English – <em>13 years in between international teams</em></li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div class="footer">
|
|
||||||
References available upon request · CV updated May 2026
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="right-col">
|
|
||||||
<!-- Photo placeholder - remove if not needed -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-130
@@ -1,130 +0,0 @@
|
|||||||
Rafael Polo
|
|
||||||
|
|
||||||
Basel, 4055, Switzerland
|
|
||||||
|
|
||||||
rafael@extrapolo.com
|
|
||||||
|
|
||||||
linkedin.com/in/rafaelpolo
|
|
||||||
|
|
||||||
extrapolo.com
|
|
||||||
|
|
||||||
Senior Software Engineer — Platform, Data & AI
|
|
||||||
|
|
||||||
Swiss B permit · Portuguese (EU) citizen
|
|
||||||
|
|
||||||
20+ years building production systems across Switzerland, Germany, Greece and Brazil. Specialised in platform
|
|
||||||
|
|
||||||
engineering, data pipelines and AI/LLM integrations. Fluent English · German B1 TELC (3 yrs Berlin + 3 yrs Basel) · EU
|
|
||||||
|
|
||||||
citizen, immediately available.
|
|
||||||
|
|
||||||
P R O F E S S I O N A L E X P E R I E N C E
|
|
||||||
|
|
||||||
Swisscom
|
|
||||||
DevOps Engineer · Zürich, Switzerland
|
|
||||||
|
|
||||||
Modernization of mission-critical internal platforms & CI/CD pipelines for high-availability Telecom infrastructure.
|
|
||||||
|
|
||||||
EDGE Empower®
|
|
||||||
|
|
||||||
Senior Technical Lead · Baar, Switzerland
|
|
||||||
|
|
||||||
Mar 2024 – Mar 2025
|
|
||||||
|
|
||||||
Feb 2023 – Feb 2024
|
|
||||||
|
|
||||||
Built a PayTool cutting processing time by 98%; reduced CI/CD pipeline from 14 → 4 minutes; managed 3 developers and a UX
|
|
||||||
|
|
||||||
designer for a SaaS product.
|
|
||||||
|
|
||||||
ExtraPolo — Independent Consulting
|
|
||||||
Software Engineer & Consultant · Rio de Janeiro · Berlin · Athens · Lisbon
|
|
||||||
|
|
||||||
2010 – 2022
|
|
||||||
|
|
||||||
40+ projects across civic tech, data journalism, open-source platforms and interactive installations for public and private clients. Includes Rodado (~730 Brazilian public datasets, 1TB Parquet, DuckDB, NL-to-SQL over MCP) and HelvetiScan (scanner/mapper of 2.5M+ Swiss .ch domains — HTTP, DNS, TLS, WHOIS, CVE).
|
|
||||||
|
|
||||||
Moedax Digital Services
|
|
||||||
|
|
||||||
Founder & CTO · Lisbon · Rio de Janeiro
|
|
||||||
|
|
||||||
2018 – 2021
|
|
||||||
|
|
||||||
Fintech startup connecting CBDC digital currency with KYC compliance automation; built data pipelines and banking integrations in
|
|
||||||
Python.
|
|
||||||
|
|
||||||
GFOSS.eu / Code + Create
|
|
||||||
Web Programming Teacher · Athens, Greece
|
|
||||||
|
|
||||||
2017 – 2018
|
|
||||||
|
|
||||||
Taught web programming (HTML, CSS, jQuery) to refugee and local youth through the EU-funded Code + Create initiative.
|
|
||||||
|
|
||||||
Tactical Tech Collective
|
|
||||||
Full-Stack Web Architect · Berlin, Germany
|
|
||||||
|
|
||||||
Dec 2014 – Jun 2016
|
|
||||||
|
|
||||||
Built investigative data tools and platforms under the Exposing the Invisible team (Ruby on Rails, data visualisation).
|
|
||||||
|
|
||||||
Trommsdorff + Drüner
|
|
||||||
|
|
||||||
Full-Stack Engineer · Berlin, Germany
|
|
||||||
|
|
||||||
Innovation team — Business Intelligence dashboards for marketing consultancy clients.
|
|
||||||
|
|
||||||
Cortex Intelligence
|
|
||||||
Developer · Rio de Janeiro, Brazil
|
|
||||||
|
|
||||||
Competitive Intelligence and Data Mining R&D team.
|
|
||||||
|
|
||||||
E D U C A T I O N
|
|
||||||
|
|
||||||
L A N G U A G E S
|
|
||||||
|
|
||||||
Dec 2013 – Jan 2014
|
|
||||||
|
|
||||||
2008 – 2010
|
|
||||||
|
|
||||||
Postgraduate in Data Science for Finance
|
|
||||||
NOVA IMS Information Management School, Lisbon — 2021
|
|
||||||
|
|
||||||
Master in Information Systems
|
|
||||||
PPGI/UNIRIO — Knowledge Representation & Reasoning —
|
|
||||||
2010–2012
|
|
||||||
|
|
||||||
Bachelor in Computer Science
|
|
||||||
Metodista Bennett, Rio de Janeiro — 2004–2009
|
|
||||||
|
|
||||||
Portuguese — native
|
|
||||||
|
|
||||||
English — full professional
|
|
||||||
|
|
||||||
Spanish — full professional
|
|
||||||
|
|
||||||
German B1 — TELC certified · 3 yrs Berlin + 3 yrs Basel
|
|
||||||
|
|
||||||
C E R T I F I C A T I O N S
|
|
||||||
|
|
||||||
Swiss Federal Act on Data Protection (nFADP)
|
|
||||||
|
|
||||||
Gen AI Foundational Models — NLP & LLMs
|
|
||||||
|
|
||||||
AI Agents using RAG and LangChain
|
|
||||||
|
|
||||||
T E C H S K I L L S
|
|
||||||
|
|
||||||
Languages & Frameworks · Python · Ruby on Rails ·
|
|
||||||
TypeScript · React
|
|
||||||
|
|
||||||
DevOps & Infra · Docker · Kubernetes · CI/CD · AWS · Linux ·
|
|
||||||
Git
|
|
||||||
|
|
||||||
Data & AI · SQL · ETL · Data Engineering · RAG · LangChain ·
|
|
||||||
LLM integrations · Data Science
|
|
||||||
|
|
||||||
Architecture · REST APIs · MVC · OOP · UML · System Design ·
|
|
||||||
Web Crawling & Scraping
|
|
||||||
|
|
||||||
References available upon request · CV updated May 2026
|
|
||||||
|
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
// bun daily --resend skip fetching, re-send today's jobs from the CSV
|
// bun daily --resend skip fetching, re-send today's jobs from the CSV
|
||||||
import { send, escapeHtml } from "./telegram.ts";
|
import { send, escapeHtml } from "./telegram.ts";
|
||||||
import { matchesProfile } from "./profile.ts";
|
import { matchesProfile } from "./profile.ts";
|
||||||
|
import { parseCsvLine } from "./csv.ts";
|
||||||
|
|
||||||
const CSV = "./results/jobs.csv";
|
const CSV = "./results/jobs.csv";
|
||||||
const SENT = "./results/sent.txt"; // links already pushed — never resend
|
const SENT = "./results/sent.txt"; // links already pushed — never resend
|
||||||
@@ -20,7 +21,7 @@ function parse(text: string): Row[] {
|
|||||||
.slice(1)
|
.slice(1)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((l) => {
|
.map((l) => {
|
||||||
const c = l.replace(/^"|"$/g, "").split('","');
|
const c = parseCsvLine(l);
|
||||||
return { fetched: c[0], posted: c[1], search: c[2], title: c[3], company: c[4], link: c[5], source: c[6], location: c[7] ?? "" };
|
return { fetched: c[0], posted: c[1], search: c[2], title: c[3], company: c[4], link: c[5], source: c[6], location: c[7] ?? "" };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -62,7 +63,55 @@ const langDropped = await Bun.file(LANG_DROPPED)
|
|||||||
.catch(() => 0);
|
.catch(() => 0);
|
||||||
|
|
||||||
// Only leads that actually match the CV (profile.ts) and haven't been sent before.
|
// Only leads that actually match the CV (profile.ts) and haven't been sent before.
|
||||||
const matched = fresh.filter((r) => !sent.has(r.link) && matchesProfile(r.title, r.company).ok);
|
const preDedupe = fresh.filter((r) => !sent.has(r.link) && matchesProfile(r.title, r.company).ok);
|
||||||
|
|
||||||
|
// Sources can surface the same underlying opening twice under different
|
||||||
|
// links/providers — e.g. englishjobsearch.ch listing the same posting on two
|
||||||
|
// canton pages with the company rendered as "Swiss AviationSoftware" on one
|
||||||
|
// and "Swiss-AS" on the other. Exact title+company catches the common case
|
||||||
|
// (same employer, identical spelling); the title-only pass catches spelling
|
||||||
|
// drift too, but only for titles specific enough (5+ words) that a
|
||||||
|
// coincidental match between two genuinely different postings is implausible.
|
||||||
|
const normTitle = (title: string) =>
|
||||||
|
title
|
||||||
|
.replace(/[([][^)\]]*[)\]]/g, "") // drop "(all genders)", "(m/w/d)", "(a)", "80-100%" noise
|
||||||
|
.replace(/[^a-z0-9 ]/gi, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const normCompany = (company: string) =>
|
||||||
|
company
|
||||||
|
.replace(/\b(ag|gmbh|ltd|llc|inc|sa|se|plc|co)\b\.?/gi, "")
|
||||||
|
.replace(/[^a-z0-9]/gi, "")
|
||||||
|
.toLowerCase();
|
||||||
|
const seenExact = new Set<string>();
|
||||||
|
const seenTitleOnly = new Set<string>();
|
||||||
|
|
||||||
|
// Seed both sets from everything already sent (any past day), keyed the same
|
||||||
|
// way — not just today's batch. Without this, a job re-dedupes only against
|
||||||
|
// jobs sent in the *same run*: LinkedIn reposts the same opening under a new
|
||||||
|
// job ID every few weeks (Optiml, LocalBini, hexagonab, nicollcurtin, etc. —
|
||||||
|
// confirmed repeats in sent.txt across different days), each repost lands
|
||||||
|
// under a fresh link that passes the `sent.has(link)` check untouched, and it
|
||||||
|
// got renotified as if new. `all` (the full CSV) still has every past row, so
|
||||||
|
// each previously-sent link resolves back to a title+company to seed with.
|
||||||
|
for (const r of all) {
|
||||||
|
if (!sent.has(r.link)) continue;
|
||||||
|
const t = normTitle(r.title);
|
||||||
|
seenExact.add(`${t}|${normCompany(r.company)}`);
|
||||||
|
if (t.split(" ").filter(Boolean).length >= 5) seenTitleOnly.add(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
const matched = preDedupe.filter((r) => {
|
||||||
|
const t = normTitle(r.title);
|
||||||
|
const exactKey = `${t}|${normCompany(r.company)}`;
|
||||||
|
if (seenExact.has(exactKey)) return false;
|
||||||
|
const specific = t.split(" ").filter(Boolean).length >= 5;
|
||||||
|
if (specific && seenTitleOnly.has(t)) return false;
|
||||||
|
seenExact.add(exactKey);
|
||||||
|
if (specific) seenTitleOnly.add(t);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
console.log(`${fresh.length} new · ${matched.length} match profile · ${fresh.length - matched.length} filtered or already sent`);
|
console.log(`${fresh.length} new · ${matched.length} match profile · ${fresh.length - matched.length} filtered or already sent`);
|
||||||
|
|
||||||
if (!matched.length) {
|
if (!matched.length) {
|
||||||
@@ -84,9 +133,10 @@ const area = (r: Row) => {
|
|||||||
if (text.includes("basel")) return "🟢 Basel";
|
if (text.includes("basel")) return "🟢 Basel";
|
||||||
if (text.includes("zürich") || text.includes("zurich")) return "🔵 Zürich";
|
if (text.includes("zürich") || text.includes("zurich")) return "🔵 Zürich";
|
||||||
if (text.includes("bern")) return "🟣 Bern";
|
if (text.includes("bern")) return "🟣 Bern";
|
||||||
|
if (text.includes("luzern") || text.includes("lucerne")) return "🟠 Luzern";
|
||||||
return "⚪️ Other";
|
return "⚪️ Other";
|
||||||
};
|
};
|
||||||
const order = ["🟢 Basel", "🌍 Remote", "🔵 Zürich", "🟣 Bern", "⚪️ Other"];
|
const order = ["🟢 Basel", "🌍 Remote", "🔵 Zürich", "🟣 Bern", "🟠 Luzern", "⚪️ Other"];
|
||||||
const groups = new Map<string, Row[]>();
|
const groups = new Map<string, Row[]>();
|
||||||
for (const r of matched) {
|
for (const r of matched) {
|
||||||
const k = area(r);
|
const k = area(r);
|
||||||
|
|||||||
+775
-13
@@ -1,5 +1,7 @@
|
|||||||
import { NON_ENGLISH_RE, JUNIOR_TITLE_RE, SOFTWARE_TITLE_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":
|
||||||
@@ -219,6 +221,155 @@ export async function fetchItJobsCh(): Promise<Job[]> {
|
|||||||
return allJobs;
|
return allJobs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// arbeitnow.ch — Swiss instance of arbeitnow, which pulls postings straight from
|
||||||
|
// company ATS feeds (Personio, Greenhouse, …). Free public JSON API, no auth:
|
||||||
|
// /api/job-board-api?page=n, 100 per page, newest first, `links.next` null on
|
||||||
|
// the last page. It's a rolling ~7-day window (measured 2026-11-03: 195 jobs,
|
||||||
|
// oldest 7 days old), so 2 requests cover the whole board. Each item carries
|
||||||
|
// the full HTML description, so the DE/FR + requiresGerman check runs here.
|
||||||
|
// pilotjob.ch was evaluated first and is NOT a source in its own right: its
|
||||||
|
// listing only offers `src=arbeitnow|jobroom`, i.e. it re-serves this feed plus
|
||||||
|
// job-room.ch, which we already sweep.
|
||||||
|
export async function fetchArbeitnow(): Promise<Job[]> {
|
||||||
|
const allJobs: Job[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (let page = 1; page <= 20; page++) {
|
||||||
|
if (page > 1) await Bun.sleep(500);
|
||||||
|
process.stdout.write(` page ${page} … `);
|
||||||
|
const res = await fetch(`https://www.arbeitnow.ch/api/job-board-api?page=${page}`, {
|
||||||
|
headers: { ...PLAIN_HEADERS, Accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (!res.ok) { console.log(`end (${res.status})`); break; }
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
data: Array<{ slug: string; company_name: string; title: string; description?: string; remote: boolean; url: string; location: string; created_at: number }>;
|
||||||
|
links: { next: string | null };
|
||||||
|
};
|
||||||
|
|
||||||
|
let kept = 0;
|
||||||
|
let langDropped = 0;
|
||||||
|
for (const item of data.data) {
|
||||||
|
const title = decodeHtml(item.title);
|
||||||
|
if (!applyFilters(title) || MS_STACK_RE.test(title) || BIO_DOMAIN_RE.test(title)) continue;
|
||||||
|
const id = `an-${item.slug}`;
|
||||||
|
if (seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
if (!isViableDescription(item.description).ok) { langDropped++; continue; }
|
||||||
|
const loc = decodeHtml(item.location ?? "");
|
||||||
|
allJobs.push({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
company: decodeHtml(item.company_name ?? ""),
|
||||||
|
location: /^remote/i.test(loc) || (item.remote && !loc) ? "Remote" : loc || "Switzerland",
|
||||||
|
postedAt: item.created_at ? new Date(item.created_at * 1000).toISOString().slice(0, 10) : "",
|
||||||
|
link: item.url,
|
||||||
|
search: "arbeitnow.ch",
|
||||||
|
source: "arbeitnow.ch",
|
||||||
|
contentChecked: true,
|
||||||
|
});
|
||||||
|
kept++;
|
||||||
|
}
|
||||||
|
console.log(`${data.data.length} items, ${kept} kept, ${langDropped} non-English`);
|
||||||
|
if (!data.links?.next || data.data.length === 0) 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<Job[]> {
|
||||||
|
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).
|
// job-room.ch — the federal employment service board (RAV / Arbeitsvermittlung).
|
||||||
// Roles under the Stellenmeldepflicht must be posted here before anywhere else,
|
// Roles under the Stellenmeldepflicht must be posted here before anywhere else,
|
||||||
// so it often carries a job days ahead of LinkedIn.
|
// so it often carries a job days ahead of LinkedIn.
|
||||||
@@ -237,7 +388,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
|
||||||
@@ -291,6 +442,16 @@ function stripTracking(url: string): string {
|
|||||||
|
|
||||||
export async function fetchJobRoom(): Promise<Job[]> {
|
export async function fetchJobRoom(): Promise<Job[]> {
|
||||||
const ads = new Map<string, JobRoomAd>();
|
const ads = new Map<string, JobRoomAd>();
|
||||||
|
// 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<string, Set<string>>();
|
||||||
|
|
||||||
for (const keyword of JOBROOM_KEYWORDS) {
|
for (const keyword of JOBROOM_KEYWORDS) {
|
||||||
process.stdout.write(` ${keyword} … `);
|
process.stdout.write(` ${keyword} … `);
|
||||||
@@ -313,7 +474,11 @@ export async function fetchJobRoom(): Promise<Job[]> {
|
|||||||
|
|
||||||
const hits = (await res.json()) as Array<{ jobAdvertisement?: JobRoomAd }>;
|
const hits = (await res.json()) as Array<{ jobAdvertisement?: JobRoomAd }>;
|
||||||
for (const h of hits) {
|
for (const h of hits) {
|
||||||
if (h.jobAdvertisement?.id) ads.set(h.jobAdvertisement.id, h.jobAdvertisement);
|
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;
|
total += hits.length;
|
||||||
if (hits.length < 100) break;
|
if (hits.length < 100) break;
|
||||||
@@ -341,8 +506,11 @@ export async function fetchJobRoom(): Promise<Job[]> {
|
|||||||
|
|
||||||
// A handful of ads ship without a description. isViableDescription passes
|
// A handful of ads ship without a description. isViableDescription passes
|
||||||
// those through by design, so leave contentChecked unset and let index.ts
|
// those through by design, so leave contentChecked unset and let index.ts
|
||||||
// fall back to fetching the page.
|
// fall back to fetching the page. When we do have variants, join every
|
||||||
const body = desc.description ?? "";
|
// 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;
|
const hasBody = body.trim().length > 40;
|
||||||
if (hasBody && !isViableDescription(body).ok) { langDropped++; continue; }
|
if (hasBody && !isViableDescription(body).ok) { langDropped++; continue; }
|
||||||
|
|
||||||
@@ -521,6 +689,50 @@ export async function fetchBaselIntlOrgs(): Promise<Job[]> {
|
|||||||
return jobs;
|
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 <li> 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<Job[]> {
|
||||||
|
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(/<h1>Join the team!<\/h1>([\s\S]*?)<footer/i);
|
||||||
|
const items = sectionM ? [...sectionM[1].matchAll(/<li[^>]*>([\s\S]*?)<\/li>/gi)] : [];
|
||||||
|
|
||||||
|
let kept = 0;
|
||||||
|
for (const [, raw] of items) {
|
||||||
|
if (/deadline has passed|interview process is (?:still\s+)?ongoing/i.test(raw)) continue;
|
||||||
|
|
||||||
|
const linkM = raw.match(/<a\s[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
|
||||||
|
const title = decodeHtml((linkM ? linkM[2] : raw).replace(/<[^>]+>/g, " "));
|
||||||
|
if (!title || !applyFilters(title)) continue;
|
||||||
|
|
||||||
|
const link = linkM ? linkM[1] : url;
|
||||||
|
jobs.push({
|
||||||
|
id: `scicore-${link.replace(/\W+/g, "").slice(-24)}`,
|
||||||
|
title,
|
||||||
|
company: "sciCORE",
|
||||||
|
location: "Basel, Switzerland",
|
||||||
|
postedAt: "",
|
||||||
|
link,
|
||||||
|
search: "scicore.unibas.ch",
|
||||||
|
source: "scicore.unibas.ch",
|
||||||
|
contentChecked: true, // listing is site-authored English; no separate description to fetch
|
||||||
|
});
|
||||||
|
kept++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` sciCORE … ${items.length} items, ${kept} kept`);
|
||||||
|
return jobs;
|
||||||
|
}
|
||||||
|
|
||||||
// jobbasel.ch — the page shell is a React/Vite SPA (no jobs in the raw HTML
|
// jobbasel.ch — the page shell is a React/Vite SPA (no jobs in the raw HTML
|
||||||
// on the homepage), but the search results route is server-rendered: fetching
|
// on the homepage), but the search results route is server-rendered: fetching
|
||||||
// `/job/suche-{slug}` returns full HTML with a `window.__PRELOADED_STATE__`
|
// `/job/suche-{slug}` returns full HTML with a `window.__PRELOADED_STATE__`
|
||||||
@@ -535,7 +747,7 @@ export async function fetchBaselIntlOrgs(): 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 {
|
||||||
@@ -708,6 +920,111 @@ export async function fetchEnglishJobSearch(): Promise<Job[]> {
|
|||||||
return jobs;
|
return jobs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// more-jobs.ch — server-rendered PHP board, an aggregator (the same posting
|
||||||
|
// often also sits on LinkedIn/jobs.ch, deduped by title+company in index.ts).
|
||||||
|
// The site is Swiss-wide with ~20 cards per page; /recherche?q= is fast on the
|
||||||
|
// first pages but deep pages take 20s+ and out-of-range pages clamp to the last
|
||||||
|
// one instead of going empty, so "no new ids" ends a keyword's sweep, not an
|
||||||
|
// empty list. Cards link to /emploi/<slug>-<id>.html; the listing carries no
|
||||||
|
// 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.
|
||||||
|
const MOREJOBS_TERMS = ["software", "engineer", "developer", "devops", "platform", "architect", "data", "python", "agentic"];
|
||||||
|
const MOREJOBS_MAX_PAGES = 5;
|
||||||
|
// The board is Swiss but lists foreign postings too ("Eastern Europe, Poland",
|
||||||
|
// "95050 Santa Clara", "London"), and its JSON-LD stamps addressCountry "CH" on
|
||||||
|
// all of them — so the card's location text is the only signal. Drop anything
|
||||||
|
// naming another country/region/foreign city, or carrying a 5-digit postcode
|
||||||
|
// (Swiss ones are 4 digits).
|
||||||
|
const MOREJOBS_FOREIGN_RE = new RegExp(
|
||||||
|
"\\b(?:usa|united states|canada|mexico|brazil|india|china|japan|singapore|thailand|australia|" +
|
||||||
|
"united kingdom|uk|england|scotland|ireland|london|cambridge|cambridgeshire|manchester|" +
|
||||||
|
"germany|deutschland|berlin|munich|m(?:ü|u)nchen|frankfurt|hamburg|stuttgart|cologne|k(?:ö|o)ln|" +
|
||||||
|
"france|paris|lyon|annecy|italy|italia|milan|milano|rome|spain|madrid|barcelona|portugal|porto|lisbon|lisboa|" +
|
||||||
|
"austria|(?:ö|o)sterreich|vienna|wien|netherlands|amsterdam|belgium|brussels|luxembourg|denmark|sweden|norway|finland|" +
|
||||||
|
"poland|warsaw|krakow|romania|bucharest|czech|prague|hungary|budapest|bulgaria|sofia|serbia|belgrade|croatia|greece|" +
|
||||||
|
"slovakia|slovenia|ukraine|turkey|israel|dubai|uae)\\b|\\b\\d{5}\\b",
|
||||||
|
"i"
|
||||||
|
);
|
||||||
|
|
||||||
|
function moreJobsLocation(raw: string): string {
|
||||||
|
// Some cards repeat the location twice ("Zurich, Switzerland Zurich, Switzerland").
|
||||||
|
const half = raw.slice(0, raw.length / 2).trim();
|
||||||
|
const loc = raw.length % 2 === 1 && raw === `${half} ${half}` ? half : raw;
|
||||||
|
return loc.replace(/(?:,\s*Switzerland)+$/i, "").replace(/^[,\s]+|[,\s]+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchMoreJobsPage(url: string): Promise<string | null> {
|
||||||
|
for (let attempt = 0; attempt < 2; attempt++) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { headers: HEADERS, signal: AbortSignal.timeout(20_000) });
|
||||||
|
if (res.ok) return await res.text();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMoreJobs(): Promise<Job[]> {
|
||||||
|
const candidates = new Map<string, { title: string; company: string; location: string; path: string }>();
|
||||||
|
|
||||||
|
for (const term of MOREJOBS_TERMS) {
|
||||||
|
process.stdout.write(` ${term} … `);
|
||||||
|
let found = 0;
|
||||||
|
for (let page = 1; page <= MOREJOBS_MAX_PAGES; page++) {
|
||||||
|
const html = await fetchMoreJobsPage(
|
||||||
|
`https://www.more-jobs.ch/recherche?q=${encodeURIComponent(term)}&page=${page}`
|
||||||
|
);
|
||||||
|
if (!html) break;
|
||||||
|
const cards = [...html.matchAll(
|
||||||
|
/<a class="positions-list-item[^"]*" href="(\/emploi\/[^"]*-(\d+)\.html)"[^>]*>\s*<h2><span>([^<]*)<\/span><\/h2>[\s\S]*?<h3><span[^>]*>([^<]*)<\/span> - <span>([^<]*)<\/span>/g
|
||||||
|
)];
|
||||||
|
let fresh = 0;
|
||||||
|
for (const [, path, id, title, company, location] of cards) {
|
||||||
|
if (candidates.has(id)) continue;
|
||||||
|
fresh++;
|
||||||
|
candidates.set(id, {
|
||||||
|
title: decodeHtml(title.trim()),
|
||||||
|
company: decodeHtml(company.trim()),
|
||||||
|
location: moreJobsLocation(decodeHtml(location.trim())),
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
found += fresh;
|
||||||
|
if (fresh === 0 || cards.length < 20) break;
|
||||||
|
await Bun.sleep(300);
|
||||||
|
}
|
||||||
|
console.log(`${found} new`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobs: Job[] = [];
|
||||||
|
const queue = [...candidates].filter(([, c]) =>
|
||||||
|
applyFilters(c.title) && !MS_STACK_RE.test(c.title) && !MOREJOBS_FOREIGN_RE.test(c.location)
|
||||||
|
);
|
||||||
|
console.log(` ${candidates.size} unique, ${queue.length} pass the title filters — fetching details`);
|
||||||
|
async function worker() {
|
||||||
|
while (queue.length) {
|
||||||
|
const [id, c] = queue.shift()!;
|
||||||
|
const html = await fetchMoreJobsPage(`https://www.more-jobs.ch${c.path}`);
|
||||||
|
if (!html) continue; // next run retries — nothing claimed yet
|
||||||
|
const body = html.match(/<div class="job-content"[^>]*>([\s\S]*?)<\/div>\s*(?:<!--|<div class="clearfix)/)?.[1] ?? "";
|
||||||
|
if (body && !isViableDescription(body).ok) continue;
|
||||||
|
const d = html.match(/Publication date<\/dt>\s*<dd>(\d{2})\.(\d{2})\.(\d{4})/);
|
||||||
|
jobs.push({
|
||||||
|
id: `morejobs-${id}`,
|
||||||
|
title: c.title,
|
||||||
|
company: c.company,
|
||||||
|
location: c.location ? `${c.location}, Switzerland` : "Switzerland",
|
||||||
|
postedAt: d ? `${d[3]}-${d[2]}-${d[1]}` : "",
|
||||||
|
link: `https://www.more-jobs.ch${c.path}`,
|
||||||
|
search: "more-jobs.ch",
|
||||||
|
source: "more-jobs.ch",
|
||||||
|
contentChecked: body.length > 40,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({ length: 4 }, worker));
|
||||||
|
return jobs;
|
||||||
|
}
|
||||||
|
|
||||||
// stellen-basel.ch — WordPress REST API
|
// stellen-basel.ch — WordPress REST API
|
||||||
export async function fetchStellenBasel(): Promise<Job[]> {
|
export async function fetchStellenBasel(): Promise<Job[]> {
|
||||||
const allJobs: Job[] = [];
|
const allJobs: Job[] = [];
|
||||||
@@ -791,6 +1108,12 @@ export async function fetchStellenBasel(): Promise<Job[]> {
|
|||||||
const DATADOME_CACHE = "results/.datadome.json";
|
const DATADOME_CACHE = "results/.datadome.json";
|
||||||
const CHROME_BIN = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
|
const CHROME_BIN = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
|
||||||
const BASELJOBS_ORIGIN = "https://basel-jobs.ch";
|
const BASELJOBS_ORIGIN = "https://basel-jobs.ch";
|
||||||
|
// Sister board on the same jobchannel network: same markup, same DataDome
|
||||||
|
// customer (one cookie, rotated or not, clears both hosts), same `/job/<slug>/<id>`
|
||||||
|
// path for a posting listed on both. Its inventory is not a subset of
|
||||||
|
// basel-jobs.ch, though — data-only postings 404 on the basel-jobs.ch host.
|
||||||
|
const DATAJOBS_ORIGIN = "https://data-jobs.ch";
|
||||||
|
const DATAJOBS_PROVINCES = "BS,BL,ZH,BE,LU";
|
||||||
|
|
||||||
interface DataDomeSession {
|
interface DataDomeSession {
|
||||||
cookie: string;
|
cookie: string;
|
||||||
@@ -967,7 +1290,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",
|
||||||
@@ -995,14 +1318,16 @@ function parseBaselJobsCards(
|
|||||||
html: string,
|
html: string,
|
||||||
seen: Set<string>,
|
seen: Set<string>,
|
||||||
out: Job[],
|
out: Job[],
|
||||||
today: string
|
today: string,
|
||||||
|
host = "basel-jobs.ch",
|
||||||
|
knownTails = new Map<string, string>()
|
||||||
): { items: number; added: number } {
|
): { items: number; added: number } {
|
||||||
const items = [...html.matchAll(/<li class="item"(.*?)<\/li>/gs)].map((m) => m[1]);
|
const items = [...html.matchAll(/<li class="item"(.*?)<\/li>/gs)].map((m) => m[1]);
|
||||||
let added = 0;
|
let added = 0;
|
||||||
|
|
||||||
for (const card of items) {
|
for (const card of items) {
|
||||||
const jid = /data-gtm-jid="(\d+)"/.exec(card)?.[1];
|
const jid = /data-gtm-jid="(\d+)"/.exec(card)?.[1];
|
||||||
const linkM = /href="(https:\/\/basel-jobs\.ch\/job\/[^"]+)"/.exec(card);
|
const linkM = /href="(https:\/\/(?:basel|data)-jobs\.ch\/job\/[^"]+)"/.exec(card);
|
||||||
const titleM = /<span class="jobtitle">(.*?)<\/span>/s.exec(card);
|
const titleM = /<span class="jobtitle">(.*?)<\/span>/s.exec(card);
|
||||||
if (!jid || !linkM || !titleM) continue;
|
if (!jid || !linkM || !titleM) continue;
|
||||||
|
|
||||||
@@ -1013,6 +1338,14 @@ function parseBaselJobsCards(
|
|||||||
seen.add(id);
|
seen.add(id);
|
||||||
added++;
|
added++;
|
||||||
|
|
||||||
|
// Already in the CSV, under either host (a posting carried by both boards
|
||||||
|
// shares its `/job/<slug>/<id>` tail, which index.ts's link dedupe can't
|
||||||
|
// see). Skipping here also spares the detail fetch below: re-checking every
|
||||||
|
// known posting on every run is what got the cookie blocked once
|
||||||
|
// data-jobs.ch's five cantons were added (measured 2026-10-22).
|
||||||
|
const tail = linkM[1].replace(/^https:\/\/[^/]+/, "");
|
||||||
|
if (knownTails.has(tail)) continue;
|
||||||
|
|
||||||
const title = decodeHtml(titleM[1]);
|
const title = decodeHtml(titleM[1]);
|
||||||
if (!applyFilters(title)) continue;
|
if (!applyFilters(title)) continue;
|
||||||
|
|
||||||
@@ -1037,19 +1370,187 @@ function parseBaselJobsCards(
|
|||||||
// best available, same as any board that hides posting age.
|
// best available, same as any board that hides posting age.
|
||||||
postedAt: today,
|
postedAt: today,
|
||||||
link: linkM[1],
|
link: linkM[1],
|
||||||
search: "basel-jobs.ch",
|
search: host,
|
||||||
source: "basel-jobs.ch",
|
source: host,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { items: items.length, added };
|
return { items: items.length, added };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// novartis.com — Rafael has an internal contact at Novartis Basel who can refer
|
||||||
|
// him, so this source watches specifically for roles that would be worth
|
||||||
|
// flagging to that contact rather than applying cold.
|
||||||
|
//
|
||||||
|
// The career-search page (Drupal Views) is server-rendered HTML, no headless
|
||||||
|
// browser needed — but only at the locale-less path (`/careers/career-search`,
|
||||||
|
// English). The `ch-de` path serves the same listing in German and its
|
||||||
|
// "job/details" links 302 back to the search page for both locales when hit
|
||||||
|
// without a referrer/session, so job detail pages are only readable through
|
||||||
|
// the English path (`/careers/career-search/job/details/req-...`), which
|
||||||
|
// resolves fine and returns the full posting.
|
||||||
|
//
|
||||||
|
// Pagination is 0-indexed (`page=0` is implicit/omitted, `page=1` is the
|
||||||
|
// second page of 10) and the page's own "Showing N results" header gives the
|
||||||
|
// exact total, so the loop bound is computed rather than probed.
|
||||||
|
//
|
||||||
|
// Novartis is 100% pharma, and its Basel HQ titling skews heavily to
|
||||||
|
// non-technical "Director, <business function>" roles (Portfolio Analytics,
|
||||||
|
// Therapeutic Area Strategy, Policy Communications, ...) plus device/hardware
|
||||||
|
// "Engineering" roles (autoinjector/drug-delivery hardware, not software).
|
||||||
|
// SOFTWARE_TITLE_RE's bare "director"/"analytics"/"ai\b" branches — tuned for
|
||||||
|
// smaller, less title-inflated boards — matched every one of those in an
|
||||||
|
// initial run (measured 2026-09-21: 13 of 47 CH listings "kept", none of them
|
||||||
|
// an actual engineering-team role). A dedicated, stricter title match is used
|
||||||
|
// here instead: it requires an explicit software/IT engineering job-family
|
||||||
|
// term, not just a seniority or business-function word BIO_DOMAIN_RE and
|
||||||
|
// PHARMA_ROLE_RE happen to miss.
|
||||||
|
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|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 —
|
||||||
|
// the inside contact is there), not all of Switzerland: Novartis CH postings
|
||||||
|
// also list Stein (Aargau) and a handful of remote/blank locations that are a
|
||||||
|
// different commute and not what the contact could refer into.
|
||||||
|
const NOVARTIS_BASE = "https://www.novartis.com";
|
||||||
|
const NOVARTIS_BASEL_RE = /\bbasel\b|\bmuttenz\b/i;
|
||||||
|
|
||||||
|
interface NovartisListing {
|
||||||
|
title: string;
|
||||||
|
href: string;
|
||||||
|
businessUnit: string;
|
||||||
|
location: string;
|
||||||
|
postedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNovartisDate(text: string): string {
|
||||||
|
const d = new Date(text.trim());
|
||||||
|
return isNaN(d.getTime()) ? "" : d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNovartisPage(html: string): { total: number; listings: NovartisListing[] } {
|
||||||
|
const totalM = /Showing\s+(\d+)\s+results/i.exec(html);
|
||||||
|
const total = totalM ? Number(totalM[1]) : 0;
|
||||||
|
|
||||||
|
const listings: NovartisListing[] = [];
|
||||||
|
const rows = [...html.matchAll(/<tr class="(?:odd|even)"[^>]*>([\s\S]*?)<\/tr>/g)];
|
||||||
|
for (const [, row] of rows) {
|
||||||
|
const titleM = /views-field-field-job-title"><a href="([^"]+)">([^<]*)<\/a>/.exec(row);
|
||||||
|
const buM = /views-field-field-job-business-unit">([^<]*)<\/td>/.exec(row);
|
||||||
|
const locM = /views-field-field-job-work-location">([^<]*)<\/td>/.exec(row);
|
||||||
|
const dateM = /views-field-field-job-posted-date">([^<]*)<\/td>/.exec(row);
|
||||||
|
if (!titleM) continue;
|
||||||
|
|
||||||
|
listings.push({
|
||||||
|
href: titleM[1],
|
||||||
|
title: decodeHtml(titleM[2]),
|
||||||
|
businessUnit: buM ? decodeHtml(buM[1]) : "",
|
||||||
|
location: locM ? decodeHtml(locM[1]) : "",
|
||||||
|
postedAt: dateM ? parseNovartisDate(dateM[1]) : "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { total, listings };
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyNovartisFilters(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;
|
||||||
|
if (BIO_DOMAIN_RE.test(title)) return false;
|
||||||
|
if (PHARMA_ROLE_RE.test(title)) return false;
|
||||||
|
return NOVARTIS_TITLE_RE.test(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchNovartisBasel(): Promise<Job[]> {
|
||||||
|
const jobs: Job[] = [];
|
||||||
|
// The listing has been observed to repeat rows across pages within one
|
||||||
|
// sweep (measured 2026-09-21: several titles appeared twice across the 5
|
||||||
|
// pages of one run) — dedupe by req id rather than trusting the pager.
|
||||||
|
const byReqId = new Map<string, NovartisListing>();
|
||||||
|
|
||||||
|
const firstHtml = await (
|
||||||
|
await fetch(`${NOVARTIS_BASE}/careers/career-search?search_api_fulltext=&country%5B0%5D=LOC_CH&field_job_posted_date=All&op=Submit`, { headers: HEADERS })
|
||||||
|
).text();
|
||||||
|
const { total, listings: firstListings } = parseNovartisPage(firstHtml);
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / 10));
|
||||||
|
console.log(` Novartis … ${total} CH results, ${totalPages} pages`);
|
||||||
|
for (const item of firstListings) {
|
||||||
|
const reqId = /req-(\d+)/.exec(item.href)?.[1];
|
||||||
|
if (reqId) byReqId.set(reqId, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let page = 1; page < totalPages; page++) {
|
||||||
|
await Bun.sleep(400);
|
||||||
|
const res = await fetch(
|
||||||
|
`${NOVARTIS_BASE}/careers/career-search?search_api_fulltext=&country%5B0%5D=LOC_CH&field_job_posted_date=All&op=Submit&page=${page}`,
|
||||||
|
{ headers: HEADERS }
|
||||||
|
);
|
||||||
|
if (!res.ok) { console.log(` Novartis … page ${page} failed (${res.status})`); break; }
|
||||||
|
const { listings } = parseNovartisPage(await res.text());
|
||||||
|
for (const item of listings) {
|
||||||
|
const reqId = /req-(\d+)/.exec(item.href)?.[1];
|
||||||
|
if (reqId) byReqId.set(reqId, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let kept = 0;
|
||||||
|
let langDropped = 0;
|
||||||
|
for (const [reqId, item] of byReqId) {
|
||||||
|
if (!NOVARTIS_BASEL_RE.test(item.location)) continue;
|
||||||
|
if (!applyNovartisFilters(item.title)) continue;
|
||||||
|
|
||||||
|
const link = `${NOVARTIS_BASE}${item.href}`;
|
||||||
|
await Bun.sleep(300);
|
||||||
|
const detailRes = await fetch(link, { headers: HEADERS });
|
||||||
|
let contentChecked = false;
|
||||||
|
if (detailRes.ok) {
|
||||||
|
const detailHtml = await detailRes.text();
|
||||||
|
const start = detailHtml.indexOf("job_details_content_center");
|
||||||
|
const end = detailHtml.indexOf("job_details_content_bottom");
|
||||||
|
if (start !== -1 && end !== -1) {
|
||||||
|
const description = detailHtml.slice(start, end).replace(/<[^>]+>/g, " ");
|
||||||
|
const check = isViableDescription(description);
|
||||||
|
if (!check.ok) { langDropped++; continue; }
|
||||||
|
contentChecked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs.push({
|
||||||
|
id: `nov-${reqId}`,
|
||||||
|
title: item.title,
|
||||||
|
company: "Novartis",
|
||||||
|
location: `${item.location}, Switzerland`,
|
||||||
|
postedAt: item.postedAt,
|
||||||
|
link,
|
||||||
|
search: "novartis.com",
|
||||||
|
source: "novartis.com",
|
||||||
|
contentChecked,
|
||||||
|
});
|
||||||
|
kept++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` Novartis … ${byReqId.size} unique CH listings, ${kept} kept (Basel-area + profile match), ${langDropped} non-English/German-required dropped`);
|
||||||
|
return jobs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `/job/<slug>/<id>` → stored link, for every basel-jobs.ch/data-jobs.ch row in the CSV. */
|
||||||
|
async function knownJobchannelTails(): Promise<Map<string, string>> {
|
||||||
|
const tails = new Map<string, string>();
|
||||||
|
try {
|
||||||
|
const text = await Bun.file("results/jobs.csv").text();
|
||||||
|
for (const link of text.match(/https:\/\/(?:basel|data)-jobs\.ch\/job\/[^\s",]+/g) ?? []) {
|
||||||
|
tails.set(link.replace(/^https:\/\/[^/]+/, ""), link);
|
||||||
|
}
|
||||||
|
} catch { /* no CSV yet */ }
|
||||||
|
return tails;
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchBaselJobs(): Promise<Job[]> {
|
export async function fetchBaselJobs(): Promise<Job[]> {
|
||||||
const allJobs: Job[] = [];
|
const allJobs: Job[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const state = { session: await loadDataDomeSession(), minted: false };
|
const state = { session: await loadDataDomeSession(), minted: false };
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const knownTails = await knownJobchannelTails();
|
||||||
|
|
||||||
// Pass 1 — the board's IT category, ~300 postings over 3 pages.
|
// Pass 1 — the board's IT category, ~300 postings over 3 pages.
|
||||||
for (let page = 1; page <= 10; page++) {
|
for (let page = 1; page <= 10; page++) {
|
||||||
@@ -1105,11 +1606,40 @@ export async function fetchBaselJobs(): Promise<Job[]> {
|
|||||||
const html = await baselJobsFetch(`${BASELJOBS_ORIGIN}/search?${qs}`, state);
|
const html = await baselJobsFetch(`${BASELJOBS_ORIGIN}/search?${qs}`, state);
|
||||||
if (html === null) { console.log("unavailable"); break; }
|
if (html === null) { console.log("unavailable"); break; }
|
||||||
|
|
||||||
const { items, added } = parseBaselJobsCards(html, seen, allJobs, today);
|
const { items, added } = parseBaselJobsCards(html, seen, allJobs, today, "basel-jobs.ch", knownTails);
|
||||||
console.log(`${items} items, ${added} new`);
|
console.log(`${items} items, ${added} new`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pass 3 — data-jobs.ch, the network's data/analytics board, filtered to
|
||||||
|
// the cantons Rafael would work in. Runs after the basel-jobs.ch passes and
|
||||||
|
// shares `seen` (vacancy ids are network-wide), so a posting on both boards
|
||||||
|
// keeps its basel-jobs.ch link. "N jobs found" is the board's own listings
|
||||||
|
// plus the "further jobs from the web search" behind `websearch=1`. The own
|
||||||
|
// listings paginate (`page=n`, 100 per page) and past the end still serve a
|
||||||
|
// few sponsored cards, so "no new ids" ends the walk; the web search ignores
|
||||||
|
// `page` and returns everything at once. Measured 2026-10-22 for
|
||||||
|
// BS,BL,ZH,BE,LU: 166 own + 98 web search = 264 unique; for BS,BL alone only
|
||||||
|
// 7 of 24 own listings were already in the CSV via basel-jobs.ch.
|
||||||
|
const dataJobsSearch = `${DATAJOBS_ORIGIN}/search?provinces=${DATAJOBS_PROVINCES}&lang=en-ch`;
|
||||||
|
for (let page = 1; page <= 10; page++) {
|
||||||
|
await Bun.sleep(400);
|
||||||
|
process.stdout.write(` data-jobs.ch page ${page} … `);
|
||||||
|
const html = await baselJobsFetch(`${dataJobsSearch}&page=${page}`, state);
|
||||||
|
if (html === null) { console.log("unavailable"); break; }
|
||||||
|
const { items, added } = parseBaselJobsCards(html, seen, allJobs, today, "data-jobs.ch", knownTails);
|
||||||
|
console.log(`${items} items, ${added} new`);
|
||||||
|
if (items === 0 || added === 0) break;
|
||||||
|
}
|
||||||
|
await Bun.sleep(400);
|
||||||
|
process.stdout.write(` data-jobs.ch websearch … `);
|
||||||
|
const webHtml = await baselJobsFetch(`${dataJobsSearch}&page=1&websearch=1`, state);
|
||||||
|
if (webHtml === null) console.log("unavailable");
|
||||||
|
else {
|
||||||
|
const { items, added } = parseBaselJobsCards(webHtml, seen, allJobs, today, "data-jobs.ch", knownTails);
|
||||||
|
console.log(`${items} items, ${added} new`);
|
||||||
|
}
|
||||||
|
|
||||||
// Detail pages carry the full description inline, so the DE/FR and
|
// Detail pages carry the full description inline, so the DE/FR and
|
||||||
// German-requirement checks run here instead of costing index.ts a fetch that
|
// German-requirement checks run here instead of costing index.ts a fetch that
|
||||||
// would hit DataDome without the cookie and be dropped as unreachable.
|
// would hit DataDome without the cookie and be dropped as unreachable.
|
||||||
@@ -1118,7 +1648,10 @@ export async function fetchBaselJobs(): Promise<Job[]> {
|
|||||||
await Bun.sleep(300);
|
await Bun.sleep(300);
|
||||||
process.stdout.write(` checking ${job.title} … `);
|
process.stdout.write(` checking ${job.title} … `);
|
||||||
const page = await baselJobsFetch(job.link, state);
|
const page = await baselJobsFetch(job.link, state);
|
||||||
if (page === null) { console.log("unreachable, keeping"); viable.push(job); continue; }
|
// Not kept: a blocked cookie fails every remaining detail page at once, and
|
||||||
|
// keeping those would land them in the CSV unchecked for good. Left out,
|
||||||
|
// they're simply retried on the next run.
|
||||||
|
if (page === null) { console.log("unreachable, skipping"); continue; }
|
||||||
|
|
||||||
// Only the posting's own text, never the page. basel-jobs.ch is a German
|
// Only the posting's own text, never the page. basel-jobs.ch is a German
|
||||||
// site, so its nav/footer/category chrome alone clears the ≥ 6 DE/FR word
|
// site, so its nav/footer/category chrome alone clears the ≥ 6 DE/FR word
|
||||||
@@ -1136,3 +1669,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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ export const BIO_DOMAIN_RE = new RegExp(
|
|||||||
"i"
|
"i"
|
||||||
);
|
);
|
||||||
|
|
||||||
export const SOFTWARE_TITLE_RE = /\b(software|developer|architect|devops|devsecops|platform|data|backend|frontend|fullstack|full.stack|tech\s*lead|technical|typescript|python|rails|node\.?js|ai\b|machine\s*learning|cloud|security|analytics|sre|reliability|infrastructure|mlops|solutions?|consultant|applications?\s*engineer|system[\s-]*engineer|informatics|scientific\s*(?:software|comput\w*|programmer)|research\s*(?:software|engineer)|hpc|forward\s*deployed|field\s*engineer|customer\s*engineer|sales\s*engineer|pre.?sales|integration\s*engineer|api\s*engineer|founding\s*engineer|product\s*engineer|automation\s*engineer|director|product\s*manager|engineering\s*manager|it\s*manager|it\s*project|staff\s*engineer|principal\s*engineer|cto|vp\s*eng)\b/i;
|
export const SOFTWARE_TITLE_RE = /\b(software|developer|architect|devops|devsecops|platform|data|backend|frontend|front.end|fullstack|full.stack|react|web\s*developer|gis|geospatial|visuali[sz]ation|webgl|graphics|creative\s*technologist|cartograph\w*|tech\s*lead|technical|typescript|python|rails|node\.?js|ai\b|agentic|llm|machine\s*learning|cloud|security|analytics|sre|reliability|infrastructure|mlops|solutions?|consultant|applications?\s*engineer|system[\s-]*engineer|informatics|scientific\s*(?:software|comput\w*|programmer)|research\s*(?:software|engineer)|hpc|forward\s*deployed|fde|palantir|field\s*engineer|customer\s*engineer|sales\s*engineer|pre.?sales|integration\s*engineer|api\s*engineer|founding\s*engineer|product\s*engineer|automation\s*engineer|director|product\s*manager|engineering\s*manager|it\s*manager|it\s*project|staff\s*engineer|principal\s*engineer|cto|vp\s*eng)\b/i;
|
||||||
|
|
||||||
const DE_CONTENT_RE = /\b(Sie|Ihre?|Ihnen|Wir|wir|und\s|für|mit\s|von\s|bei\s|das\s|ist\s|sind\s|haben\s|werden\s|können|suchen|bieten|Kenntnisse|Erfahrung|Anforderungen|Aufgaben|Stellenbeschreibung|Stellenangebot|Bewerbung|Ausbildung|Berufserfahrung|Teamarbeit|Vollzeit|Teilzeit|unbefristet|Abschluss|gesucht|Deutsch|Englisch|Schweizer)\b/g;
|
const DE_CONTENT_RE = /\b(Sie|Ihre?|Ihnen|Wir|wir|und\s|für|mit\s|von\s|bei\s|das\s|ist\s|sind\s|haben\s|werden\s|können|suchen|bieten|Kenntnisse|Erfahrung|Anforderungen|Aufgaben|Stellenbeschreibung|Stellenangebot|Bewerbung|Ausbildung|Berufserfahrung|Teamarbeit|Vollzeit|Teilzeit|unbefristet|Abschluss|gesucht|Deutsch|Englisch|Schweizer)\b/g;
|
||||||
const FR_CONTENT_RE = /\b(vous|nous|votre|notre|pour\s|avec\s|profil|poste|emploi|entreprise|compétences|expérience|formation|équipe|rejoindre|recherchons|offrons|missions|candidature|CDI|CDD|temps\s+plein|temps\s+partiel)\b/g;
|
const FR_CONTENT_RE = /\b(vous|nous|votre|notre|pour\s|avec\s|profil|poste|emploi|entreprise|compétences|expérience|formation|équipe|rejoindre|recherchons|offrons|missions|candidature|CDI|CDD|temps\s+plein|temps\s+partiel)\b/g;
|
||||||
@@ -200,6 +200,22 @@ const GERMAN_OPTIONAL_RE =
|
|||||||
const GERMAN_REQUIRED_RE =
|
const GERMAN_REQUIRED_RE =
|
||||||
/\b(?:required|require[sd]?|must|mandatory|essential|necessary|fluen\w*|proficien\w*|excellent|strong|very\s+good|good\s+(?:knowledge|command|level)|native|mother\s*tongue|business\s+level|negotiat\w*|B2|C1|C2|verhandlungssicher\w*|fliessend\w*|fließend\w*|muttersprach\w*|sehr\s+gut\w*|gut\w*\s+Deutschkenntnisse|Deutschkenntnisse|voraussetzung|zwingend|erforderlich)\b/i;
|
/\b(?:required|require[sd]?|must|mandatory|essential|necessary|fluen\w*|proficien\w*|excellent|strong|very\s+good|good\s+(?:knowledge|command|level)|native|mother\s*tongue|business\s+level|negotiat\w*|B2|C1|C2|verhandlungssicher\w*|fliessend\w*|fließend\w*|muttersprach\w*|sehr\s+gut\w*|gut\w*\s+Deutschkenntnisse|Deutschkenntnisse|voraussetzung|zwingend|erforderlich)\b/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "German is <verdict>" / "proficiency in German is considered <verdict>" —
|
||||||
|
* the copula directly after the mention names the mention's own predicate,
|
||||||
|
* which should decide the case outright rather than compete on raw distance
|
||||||
|
* with unrelated markers elsewhere in the window. Swisscom's stock line
|
||||||
|
* "Fluency in English is required; proficiency in German is considered an
|
||||||
|
* advantage" put a required-marker word ("proficiency") right next to
|
||||||
|
* "German" and an optional-marker word ("advantage") further away — nearest()
|
||||||
|
* picked "proficiency" and wrongly called it required. The predicate right
|
||||||
|
* after the mention ("is considered an advantage") is what actually decides.
|
||||||
|
* Only fires when a copula immediately follows; "Work proficiency in German,
|
||||||
|
* English and French" (no copula after "German") correctly falls through to
|
||||||
|
* the general nearest-marker logic below.
|
||||||
|
*/
|
||||||
|
const GERMAN_LOCAL_PREDICATE_RE = /^\s*,?\s*(?:is|are)\s+(?:considered\s+|deemed\s+)?/i;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spec-sheet style language line ("Languages: German & English"), common on
|
* Spec-sheet style language line ("Languages: German & English"), common on
|
||||||
* contractor postings. There is no adjective for the nearest-marker logic to
|
* contractor postings. There is no adjective for the nearest-marker logic to
|
||||||
@@ -261,6 +277,16 @@ export function requiresGerman(text: string): boolean {
|
|||||||
|
|
||||||
for (const m of flat.matchAll(GERMAN_MENTION_RE)) {
|
for (const m of flat.matchAll(GERMAN_MENTION_RE)) {
|
||||||
const i = m.index ?? 0;
|
const i = m.index ?? 0;
|
||||||
|
|
||||||
|
// The mention's own predicate ("German is <verdict>") overrides distance.
|
||||||
|
const after = flat.slice(i + m[0].length, i + m[0].length + 60);
|
||||||
|
const predicateM = after.match(GERMAN_LOCAL_PREDICATE_RE);
|
||||||
|
if (predicateM) {
|
||||||
|
const rest = after.slice(predicateM[0].length);
|
||||||
|
if (new RegExp(`^${GERMAN_OPTIONAL_RE.source}`, "i").test(rest)) continue;
|
||||||
|
if (new RegExp(`^${GERMAN_REQUIRED_RE.source}`, "i").test(rest)) return true;
|
||||||
|
}
|
||||||
|
|
||||||
const from = Math.max(0, i - 110);
|
const from = Math.max(0, i - 110);
|
||||||
const window = flat.slice(from, i + 110);
|
const window = flat.slice(from, i + 110);
|
||||||
const at = i - from; // mention's offset inside the window
|
const at = i - from; // mention's offset inside the window
|
||||||
@@ -275,7 +301,13 @@ export function requiresGerman(text: string): boolean {
|
|||||||
export async function fetchDescription(id: string): Promise<string | null> {
|
export async function fetchDescription(id: string): Promise<string | null> {
|
||||||
for (let attempt = 0; attempt < 3; attempt++) {
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
try {
|
try {
|
||||||
await Bun.sleep(350 * (attempt + 1));
|
// 429s need the same backoff as the listing-page retry (1s/2s/4s) — the
|
||||||
|
// old 350/700/1050ms wait was too short under concurrent load, so this
|
||||||
|
// exhausted its retries while still rate-limited and fell through to the
|
||||||
|
// "can't read it, so keep" default below, letting German postings (e.g.
|
||||||
|
// CLEEVEN, job 4465075383, all-German with zero English content) slip
|
||||||
|
// past isViableContent entirely on 2026-09-10.
|
||||||
|
await Bun.sleep(1000 * 2 ** attempt);
|
||||||
const res = await fetch(`https://www.linkedin.com/jobs/view/${id}/`, { headers: HEADERS });
|
const res = await fetch(`https://www.linkedin.com/jobs/view/${id}/`, { headers: HEADERS });
|
||||||
const html = await res.text();
|
const html = await res.text();
|
||||||
const m = html.match(/class="show-more-less-html__markup[^"]*">([\s\S]{0,8000}?)<\/div>/);
|
const m = html.match(/class="show-more-less-html__markup[^"]*">([\s\S]{0,8000}?)<\/div>/);
|
||||||
@@ -289,10 +321,21 @@ export async function fetchDescription(id: string): Promise<string | null> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** English-language AND not demanding German above B1. */
|
/**
|
||||||
export async function isViableContent(id: string): Promise<{ ok: boolean; reason?: string }> {
|
* English-language AND not demanding German above B1.
|
||||||
|
*
|
||||||
|
* `retry: true` means the description couldn't be read at all (rate-limited,
|
||||||
|
* stub page) — index.ts leaves the job out of the CSV entirely rather than
|
||||||
|
* defaulting to "keep", so it's picked up fresh and re-checked next run
|
||||||
|
* instead of ever being judged sight-unseen. The old default-to-keep here is
|
||||||
|
* what let a fully-German HRM Systems/Consult & Pepper posting ("In Deutsch
|
||||||
|
* bist du verhandlungssicher") through on 2026-09-10 — same failure mode as
|
||||||
|
* the CLEEVEN case above, just on the "keep" side of isViableContent instead
|
||||||
|
* of isEnglishContent.
|
||||||
|
*/
|
||||||
|
export async function isViableContent(id: string): Promise<{ ok: boolean; reason?: string; retry?: boolean }> {
|
||||||
const text = await fetchDescription(id);
|
const text = await fetchDescription(id);
|
||||||
if (text === null) return { ok: true }; // keep when we genuinely can't read it
|
if (text === null) return { ok: false, reason: "unreadable — retrying next run", retry: true };
|
||||||
if (hasNonEnglishUmlaut(text)) return { ok: false, reason: "non-English (umlaut)" };
|
if (hasNonEnglishUmlaut(text)) return { ok: false, reason: "non-English (umlaut)" };
|
||||||
const de = (text.match(DE_CONTENT_RE) ?? []).length;
|
const de = (text.match(DE_CONTENT_RE) ?? []).length;
|
||||||
const fr = (text.match(FR_CONTENT_RE) ?? []).length;
|
const fr = (text.match(FR_CONTENT_RE) ?? []).length;
|
||||||
@@ -307,7 +350,8 @@ export async function isEnglishContent(id: string): Promise<boolean> {
|
|||||||
// false "English" result. Back off and try again before falling back.
|
// false "English" result. Back off and try again before falling back.
|
||||||
for (let attempt = 0; attempt < 3; attempt++) {
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
try {
|
try {
|
||||||
await Bun.sleep(350 * (attempt + 1));
|
// Same 1s/2s/4s backoff as fetchDescription — see its comment.
|
||||||
|
await Bun.sleep(1000 * 2 ** attempt);
|
||||||
const res = await fetch(`https://www.linkedin.com/jobs/view/${id}/`, { headers: HEADERS });
|
const res = await fetch(`https://www.linkedin.com/jobs/view/${id}/`, { headers: HEADERS });
|
||||||
const html = await res.text();
|
const html = await res.text();
|
||||||
const m = html.match(/class="show-more-less-html__markup[^"]*">([\s\S]{0,8000}?)<\/div>/);
|
const m = html.match(/class="show-more-less-html__markup[^"]*">([\s\S]{0,8000}?)<\/div>/);
|
||||||
@@ -337,11 +381,13 @@ export async function isEnglishUrl(url: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** As isViableContent, but for an arbitrary job URL (non-LinkedIn sources). */
|
/** As isViableContent, but for an arbitrary job URL (non-LinkedIn sources). */
|
||||||
export async function isViableUrl(url: string): Promise<{ ok: boolean; reason?: string }> {
|
export async function isViableUrl(url: string): Promise<{ ok: boolean; reason?: string; retry?: boolean }> {
|
||||||
try {
|
try {
|
||||||
await Bun.sleep(350);
|
await Bun.sleep(350);
|
||||||
const res = await fetch(url, { headers: HEADERS });
|
const res = await fetch(url, { headers: HEADERS });
|
||||||
if (!res.ok) return { ok: true };
|
// Same reasoning as isViableContent: an unreadable page shouldn't default
|
||||||
|
// to "keep" — defer it and let next run's fresh fetch actually judge it.
|
||||||
|
if (!res.ok) return { ok: false, reason: "unreadable — retrying next run", retry: true };
|
||||||
const text = (await res.text()).replace(/<[^>]+>/g, " ");
|
const text = (await res.text()).replace(/<[^>]+>/g, " ");
|
||||||
if (hasNonEnglishUmlaut(text)) return { ok: false, reason: "non-English (umlaut)" };
|
if (hasNonEnglishUmlaut(text)) return { ok: false, reason: "non-English (umlaut)" };
|
||||||
const de = (text.match(DE_CONTENT_RE) ?? []).length;
|
const de = (text.match(DE_CONTENT_RE) ?? []).length;
|
||||||
@@ -350,7 +396,7 @@ export async function isViableUrl(url: string): Promise<{ ok: boolean; reason?:
|
|||||||
if (requiresGerman(text)) return { ok: false, reason: "requires German > B1" };
|
if (requiresGerman(text)) return { ok: false, reason: "requires German > B1" };
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch {
|
} catch {
|
||||||
return { ok: true };
|
return { ok: false, reason: "unreadable — retrying next run", retry: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
import { searches } from "./config.ts";
|
import { searches } from "./config.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 } from "./fetch-other.ts";
|
import { fetchDataCareer, fetchStellenBasel, fetchItJobsCh, fetchJobRoom, fetchBaselIntlOrgs, fetchJobBasel, fetchEnglishJobSearch, fetchBaselJobs, fetchSciCore, fetchSwisscom, fetchNovartisBasel, fetchMoreJobs, fetchStartupJobs, fetchArbeitnow } 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";
|
||||||
@@ -47,12 +48,17 @@ if (csvExists) {
|
|||||||
// posting on every run (found 2026-09-08: 4 duplicate rows for one link,
|
// posting on every run (found 2026-09-08: 4 duplicate rows for one link,
|
||||||
// added on both 09-07 and 09-08).
|
// added on both 09-07 and 09-08).
|
||||||
const existingLinks = new Set<string>();
|
const existingLinks = new Set<string>();
|
||||||
|
// Normalized title+company of every row already in the CSV, for aggregator
|
||||||
|
// sources that repost jobs other sources carry (see `dedupeByTitle`).
|
||||||
|
const knownJobKeys = new Set<string>();
|
||||||
try {
|
try {
|
||||||
const text = await Bun.file(CSV).text();
|
const text = await Bun.file(CSV).text();
|
||||||
for (const line of text.split("\n").slice(1)) {
|
for (const line of text.split("\n").slice(1)) {
|
||||||
if (!line.trim()) continue;
|
if (!line.trim()) continue;
|
||||||
const link = line.replace(/^"|"$/g, "").split('","')[5];
|
const cells = parseCsvLine(line);
|
||||||
|
const link = cells[5];
|
||||||
if (link) existingLinks.add(link);
|
if (link) existingLinks.add(link);
|
||||||
|
if (cells[3]) knownJobKeys.add(langKey(cells[3], cells[4] ?? ""));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// first run
|
// first run
|
||||||
@@ -62,6 +68,36 @@ const date = new Date().toISOString().slice(0, 10);
|
|||||||
const newRows: string[] = [];
|
const newRows: string[] = [];
|
||||||
let totalLangDropped = 0; // jobs dropped for German/French content — never reach the CSV, so daily.ts can't see them on its own
|
let totalLangDropped = 0; // jobs dropped for German/French content — never reach the CSV, so daily.ts can't see them on its own
|
||||||
|
|
||||||
|
// Persisted across runs so the Telegram digest's "Ignored (German/French)"
|
||||||
|
// count reflects genuinely new drops, not the same posting getting re-flagged
|
||||||
|
// every run (dropped jobs never reach the CSV, so nothing else remembers
|
||||||
|
// them) or the same underlying job counted again each time it resurfaces
|
||||||
|
// under a different link/provider (e.g. Consult & Pepper's "Senior Fullstack
|
||||||
|
// Software Engineer" reposted on job-room.ch every few days, and separately
|
||||||
|
// on LinkedIn) — keyed on normalized title+company rather than the link.
|
||||||
|
const LANG_SEEN = "./results/.lang-dropped-seen.json";
|
||||||
|
const langSeen = new Set<string>(
|
||||||
|
await Bun.file(LANG_SEEN)
|
||||||
|
.json()
|
||||||
|
.catch(() => [])
|
||||||
|
);
|
||||||
|
function langKey(title: string, company: string): string {
|
||||||
|
const t = title
|
||||||
|
.replace(/[([][^)\]]*[)\]]/g, "") // drop "(all genders)", "(m/w/d)", "(a)", "(80 – 100%)" noise
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
return `${t}|${company.trim().toLowerCase()}`;
|
||||||
|
}
|
||||||
|
// Only counts (and remembers) a drop the first time this title+company is
|
||||||
|
// seen; still runs for every dropped job so `langSeen` stays complete.
|
||||||
|
function countLangDrop(title: string, company: string): void {
|
||||||
|
const key = langKey(title, company);
|
||||||
|
if (langSeen.has(key)) return;
|
||||||
|
langSeen.add(key);
|
||||||
|
totalLangDropped++;
|
||||||
|
}
|
||||||
|
|
||||||
function csvCell(val: string): string {
|
function csvCell(val: string): string {
|
||||||
return `"${val.replace(/"/g, '""')}"`;
|
return `"${val.replace(/"/g, '""')}"`;
|
||||||
}
|
}
|
||||||
@@ -115,13 +151,21 @@ async function processLinkedInSearch(config: (typeof searches)[number]): Promise
|
|||||||
// One fetch, two checks: written in German/French, or written in English
|
// One fetch, two checks: written in German/French, or written in English
|
||||||
// but demanding German above B1.
|
// but demanding German above B1.
|
||||||
const viable = await isViableContent(job.id);
|
const viable = await isViableContent(job.id);
|
||||||
|
if (viable.retry) {
|
||||||
|
// Couldn't read the description at all — leave it out of the CSV so
|
||||||
|
// next run's fresh fetch judges it properly, instead of defaulting to
|
||||||
|
// "keep" and risking a German-requiring posting slipping through.
|
||||||
|
console.log(viable.reason);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!viable.ok) {
|
if (!viable.ok) {
|
||||||
console.log(`dropped (${viable.reason})`);
|
console.log(`dropped (${viable.reason})`);
|
||||||
langDropped++;
|
langDropped++;
|
||||||
totalLangDropped++;
|
countLangDrop(job.title, job.company);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
console.log("ok");
|
console.log("ok");
|
||||||
|
knownJobKeys.add(langKey(job.title, job.company));
|
||||||
newRows.push(
|
newRows.push(
|
||||||
[date, job.postedAt, config.label, job.title, job.company, job.link, "linkedin.com", job.location]
|
[date, job.postedAt, config.label, job.title, job.company, job.link, "linkedin.com", job.location]
|
||||||
.map(csvCell)
|
.map(csvCell)
|
||||||
@@ -132,7 +176,7 @@ async function processLinkedInSearch(config: (typeof searches)[number]): Promise
|
|||||||
console.log(` [${config.label}] ${jobs.length} found, ${added} new, ${langDropped} non-English dropped`);
|
console.log(` [${config.label}] ${jobs.length} found, ${added} new, ${langDropped} non-English dropped`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const extraSources: Array<{ label: string; fetch: () => ReturnType<typeof fetchDataCareer> }> = [
|
const extraSources: Array<{ label: string; fetch: () => ReturnType<typeof fetchDataCareer>; dedupeByTitle?: boolean }> = [
|
||||||
{ label: "datacareer.ch", fetch: fetchDataCareer },
|
{ label: "datacareer.ch", fetch: fetchDataCareer },
|
||||||
{ label: "stellen-basel.ch", fetch: fetchStellenBasel },
|
{ label: "stellen-basel.ch", fetch: fetchStellenBasel },
|
||||||
{ label: "itjobs.ch", fetch: fetchItJobsCh },
|
{ label: "itjobs.ch", fetch: fetchItJobsCh },
|
||||||
@@ -141,9 +185,19 @@ const extraSources: Array<{ label: string; fetch: () => ReturnType<typeof fetchD
|
|||||||
{ label: "jobbasel.ch", fetch: fetchJobBasel },
|
{ label: "jobbasel.ch", fetch: fetchJobBasel },
|
||||||
{ label: "englishjobsearch.ch", fetch: fetchEnglishJobSearch },
|
{ label: "englishjobsearch.ch", fetch: fetchEnglishJobSearch },
|
||||||
{ label: "basel-jobs.ch", fetch: fetchBaselJobs },
|
{ label: "basel-jobs.ch", fetch: fetchBaselJobs },
|
||||||
|
{ label: "scicore.unibas.ch", fetch: fetchSciCore },
|
||||||
|
{ label: "swisscom.com", fetch: fetchSwisscom },
|
||||||
|
{ label: "novartis.com", fetch: fetchNovartisBasel },
|
||||||
|
// Aggregator: reposts what LinkedIn/jobs.ch/etc. already carry, so also skip
|
||||||
|
// anything whose normalized title+company is already in the CSV.
|
||||||
|
{ 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 },
|
||||||
|
// ATS-fed board that overlaps ~50% with LinkedIn (measured 2026-11-03).
|
||||||
|
{ label: "arbeitnow.ch", fetch: fetchArbeitnow, dedupeByTitle: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
async function processExtraSource({ label, fetch: fetchFn }: (typeof extraSources)[number]): Promise<void> {
|
async function processExtraSource({ label, fetch: fetchFn, dedupeByTitle }: (typeof extraSources)[number]): Promise<void> {
|
||||||
console.log(`\n[${label}]`);
|
console.log(`\n[${label}]`);
|
||||||
let jobs;
|
let jobs;
|
||||||
try {
|
try {
|
||||||
@@ -163,18 +217,28 @@ async function processExtraSource({ label, fetch: fetchFn }: (typeof extraSource
|
|||||||
for (const job of jobs) {
|
for (const job of jobs) {
|
||||||
if (existingLinks.has(job.link)) continue;
|
if (existingLinks.has(job.link)) continue;
|
||||||
existingLinks.add(job.link); // claim before the await — see note above
|
existingLinks.add(job.link); // claim before the await — see note above
|
||||||
|
if (dedupeByTitle) {
|
||||||
|
const key = langKey(job.title, job.company);
|
||||||
|
if (knownJobKeys.has(key)) continue;
|
||||||
|
knownJobKeys.add(key); // same synchronous-claim rule as the link
|
||||||
|
}
|
||||||
|
|
||||||
if (!job.contentChecked) {
|
if (!job.contentChecked) {
|
||||||
process.stdout.write(` [${label}] checking content: ${job.title} … `);
|
process.stdout.write(` [${label}] checking content: ${job.title} … `);
|
||||||
const viable = await isViableUrl(job.link);
|
const viable = await isViableUrl(job.link);
|
||||||
|
if (viable.retry) {
|
||||||
|
console.log(viable.reason);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!viable.ok) {
|
if (!viable.ok) {
|
||||||
console.log(`dropped (${viable.reason})`);
|
console.log(`dropped (${viable.reason})`);
|
||||||
langDropped++;
|
langDropped++;
|
||||||
totalLangDropped++;
|
countLangDrop(job.title, job.company);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
console.log("ok");
|
console.log("ok");
|
||||||
}
|
}
|
||||||
|
knownJobKeys.add(langKey(job.title, job.company));
|
||||||
newRows.push(
|
newRows.push(
|
||||||
[date, job.postedAt, job.search, job.title, job.company, job.link, job.source, job.location]
|
[date, job.postedAt, job.search, job.title, job.company, job.link, job.source, job.location]
|
||||||
.map(csvCell)
|
.map(csvCell)
|
||||||
@@ -198,6 +262,7 @@ await Promise.all([
|
|||||||
// without listing rejects that were never even parsed into a Row.
|
// without listing rejects that were never even parsed into a Row.
|
||||||
const LANG_DROPPED = "./results/.lang-dropped.json";
|
const LANG_DROPPED = "./results/.lang-dropped.json";
|
||||||
await Bun.write(LANG_DROPPED, JSON.stringify({ date, count: totalLangDropped }));
|
await Bun.write(LANG_DROPPED, JSON.stringify({ date, count: totalLangDropped }));
|
||||||
|
await Bun.write(LANG_SEEN, JSON.stringify([...langSeen]));
|
||||||
|
|
||||||
if (newRows.length === 0) {
|
if (newRows.length === 0) {
|
||||||
console.log("\nNo new jobs.");
|
console.log("\nNo new jobs.");
|
||||||
|
|||||||
+1
-1
@@ -9,6 +9,6 @@
|
|||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "^1.60.0"
|
"playwright-core": "^1.63.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# LinkedIn Featured
|
||||||
|
|
||||||
|
Order matters: the carousel shows the first 2–3. Add each as **Add a link**, then edit the title and description LinkedIn pulls from the page.
|
||||||
|
|
||||||
|
Audience: the targets in `applied.md`, which fall into three groups: data/AI platform (MDPI, Adobe, Swisscom), security (Swiss Re, Swissgrid, Helvetia, Isovalent, Kaduu) and Basel/Zug finance (Partners Group, Julius Baer, J. Safra Sarasin, BIS).
|
||||||
|
|
||||||
|
| # | Project | Link | Speaks to |
|
||||||
|
|---|---------|------|-----------|
|
||||||
|
| 1 | Rodado | https://rodado.xyz | data/AI platform |
|
||||||
|
| 2 | HelvetiScan | https://github.com/rafapolo/helvetiscan | security, Swiss |
|
||||||
|
| 3 | FinCrime | https://rafapolo.github.io/fincrime/ | finance, AML |
|
||||||
|
| 4 | swissviz | https://rafapolo.github.io/swissviz/ | Swiss, visual |
|
||||||
|
| 5 | brasilume | https://rafapolo.github.io/brasilume/ | data quality, visual |
|
||||||
|
|
||||||
|
## Copy
|
||||||
|
|
||||||
|
**1 · Rodado: Brazilian public data for AI agents**
|
||||||
|
1,050 official tables in a DuckDB/Parquet mirror, exposed as an MCP server, with the column bridges and metric definitions an agent needs to join them correctly.
|
||||||
|
|
||||||
|
**2 · HelvetiScan: Switzerland's digital surface**
|
||||||
|
A scan of ~2.5M .ch domains (HTTP, DNS, TLS, WHOIS, CVE) with sector classification and risk benchmarking. Written in Rust.
|
||||||
|
|
||||||
|
**3 · Redes Obscuras: ownership graphs for financial-crime investigation**
|
||||||
|
An interactive company↔partner network around an asset manager in a Brazilian money-laundering investigation, built from public registry data.
|
||||||
|
|
||||||
|
**4 · swissviz: every company in Switzerland on one map**
|
||||||
|
Swiss commercial-register entities geocoded and rendered on the GPU, in the browser, with no backend.
|
||||||
|
|
||||||
|
**5 · brasilume: 21 million businesses, matched address by address**
|
||||||
|
Brazil's active establishments placed by exact address match against the 2022 Census, with no postcode centroids, rendered in the browser.
|
||||||
|
|
||||||
|
## Left out on purpose
|
||||||
|
|
||||||
|
- Eleições 2026, Pulso da Imprensa, Templos, religioes: strong, but Brazilian political/religious topics read as noise or friction to a Swiss recruiter. They stay on extrapolo.com.
|
||||||
|
- Old client sites (Etaoin, Av. Atlântica, Victor Haim): career depth, not a differentiator for these roles.
|
||||||
|
- Alternative for slot 4: link extrapolo.com instead of swissviz, so the whole portfolio is one click away.
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Post 3 — Jun 17 · Rodado + datative
|
|
||||||
|
|
||||||
**Angle:** Data engineering at scale, pipeline story
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
675 GB. 533 datasets. One question: what do you actually do with Brazilian open data?
|
|
||||||
|
|
||||||
The government publishes it. The files exist. But they're scattered across formats, encodings, and schemas that change without notice. Most of it is effectively inaccessible to anyone who isn't willing to spend weeks just cleaning.
|
|
||||||
|
|
||||||
**Rodado** is my answer to that: a pipeline that organizes, normalizes, and indexes the full archive of Brazilian public datasets — census data, company registries, electoral records, public spending — into something you can actually query.
|
|
||||||
|
|
||||||
On top of that I built **datative**, an investigation platform that turns any of those datasets into interactive force-graphs. It was designed for journalists and researchers who need to follow connections, not just rows.
|
|
||||||
|
|
||||||
Both open source. The pipeline patterns transfer directly to any domain — scientific data, financial records, public health.
|
|
||||||
|
|
||||||
🔗 Rodado: git.extrapolo.com/polo/baseldosdados
|
|
||||||
🔗 datative: github.com/rafapolo/datative
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Post 3 — Oct 14 · Rodado
|
||||||
|
|
||||||
|
**Angle:** AI agents over a real data platform; the hard part is the joins, not the data
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
In my first two posts I mapped every company in Switzerland and every .ch domain. This one goes back to where I come from: Brazilian public data.
|
||||||
|
|
||||||
|
**Rodado** is a mirror of 1,050 official Brazilian tables (238 datasets: employment records, death certificates, elections, public contracts, census) plus 77+ sources I scrape myself, like international sanctions lists and the ICIJ Offshore Leaks. All in Parquet, all queryable with DuckDB, and exposed as an MCP server so an AI agent can explore it directly.
|
||||||
|
|
||||||
|
Getting the data was the easy part. The hard part is that nothing has a foreign key. A municipality is `id_municipio` in one table, `codIBGE` in another, and sometimes arrives as a number with the leading zero gone. A column called `valor` appears in 91 tables and means something different in most of them; join on it and you get a big, plausible, wrong answer.
|
||||||
|
|
||||||
|
So the platform ships its own context for the agent: 54 bridges between equivalent columns, each with the SQL that converts one side into the other, plus single definitions for the recurring metrics. That context is what turns "an LLM with database access" into something that gives answers you can check.
|
||||||
|
|
||||||
|
The site also has 43 themes that read the tables side by side, on inequality, labour, health and power.
|
||||||
|
|
||||||
|
🔗 rodado.xyz
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Before posting:**
|
||||||
|
- [ ] Check the numbers against rodado.xyz on the day (tables, datasets, scraped sources, bridges)
|
||||||
|
- [ ] MCP access is restricted to selected members; say "access on request" in the comments if anyone asks
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Post 4 — Oct 21 · brasilume
|
||||||
|
|
||||||
|
**Angle:** entity resolution and data quality, shown as a map
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Brazil has 26.9 million active business establishments. Each one has an address in the federal company registry. Putting them on a map sounds trivial until you try.
|
||||||
|
|
||||||
|
The registry addresses are typed by hand over decades: abbreviations, missing street titles ("Professora", "Coronel"), postcodes that are wrong or out of date. The usual shortcut is to drop every company on the centre of its postcode or street, which produces a nice-looking map that is mostly invented.
|
||||||
|
|
||||||
|
**brasilume** does it the slow way: it matches each address against the 2022 Census address registry (IBGE's CNEFE) and only places a point when the street and the exact number or lot match. No postcode centroids, no street centroids. 21.4 million establishments made it, 80% of the active ones. The rest stay off the map instead of being guessed.
|
||||||
|
|
||||||
|
Everything runs in the browser, with no server and no tile service: points stored as delta-encoded varints in Morton order, about 2.7× smaller than raw floats, a 2-million-point sample for the national view, and the full state loading when you zoom in. You can filter by economic sector or watch the country fill up by opening year.
|
||||||
|
|
||||||
|
The matching rules are the part I'd reuse anywhere addresses, names or IDs don't line up — which is most real data.
|
||||||
|
|
||||||
|
🔗 rafapolo.github.io/brasilume
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Before posting:**
|
||||||
|
- [ ] Confirm the active/mapped totals on the live Brazil view (26,865,736 active · 21,400,627 mapped on 2026-10-06)
|
||||||
|
- [ ] Attach a screen recording: zoom from Brazil into São Paulo, then scrub the opening-year timeline
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# Post 4 — Jun 24 · Tocador
|
|
||||||
|
|
||||||
**Angle:** Product + design + cultural identity
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
I moved from Brazil to Berlin to Basel. The music came with me — but Spotify's algorithm never quite figured out what I was actually listening to.
|
|
||||||
|
|
||||||
So I built my own player.
|
|
||||||
|
|
||||||
**Tocador** is a Spotify-alternative with a catalogue of 7,000+ Brazilian artists, curated and indexed by hand. No recommendation algorithm, no ads, no "because you listened to X." Just artists, genres, and a search that works.
|
|
||||||
|
|
||||||
It's live at **tocador.cc** — and it still runs, four years later.
|
|
||||||
|
|
||||||
I built it in a weekend because I was frustrated. It ended up teaching me more about product design, music metadata, and what "simple" actually requires than most client projects I've taken on.
|
|
||||||
|
|
||||||
Sometimes the side projects that start from irritation are the ones that last.
|
|
||||||
|
|
||||||
🔗 tocador.cc
|
|
||||||
🔗 github.com/rafapolo/tocador
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Post 5 — Oct 28 · FinCrime (Redes Obscuras)
|
||||||
|
|
||||||
|
**Angle:** ownership graphs for financial-crime and AML investigation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
In 2025 Brazil's Federal Revenue reported that organised crime controlled at least 40 investment funds with more than R$30 billion in assets. The funds were legal. What hid them was the structure: companies owning companies, partners repeating across dozens of them, layers that look like nothing in a spreadsheet.
|
||||||
|
|
||||||
|
**Redes Obscuras** is the tool I built to look at one of those structures: the asset manager REAG, its companies, their partners, and the companies those partners also hold. Every entity is a node; every shareholding is an edge. Search a name and the graph centres on it; click any node and you can walk its connections one hop at a time. Colour does the first pass of analysis: the core companies, the people, the extended network, all visible at a glance.
|
||||||
|
|
||||||
|
It's built on public company-registry data and runs entirely in the browser.
|
||||||
|
|
||||||
|
The same approach works anywhere beneficial ownership matters — KYC, sanctions screening, due diligence. The tables are usually already there. What is missing is a way to see three hops away.
|
||||||
|
|
||||||
|
🔗 rafapolo.github.io/fincrime
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Before posting:**
|
||||||
|
- [ ] Fact-check the opening: the Receita Federal figure (40+ funds, R$30bn+) and REAG's link to the investigation, with a news source to add in the comments
|
||||||
|
- [ ] Keep the wording to what the public data shows; no claims about individuals
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# Post 5 — Jul 1 · VISO
|
|
||||||
|
|
||||||
**Angle:** Civic accountability, live SQL, dataviz with substance
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Parliamentary expenses are public. They're also 400,000 rows of CSV that nobody reads.
|
|
||||||
|
|
||||||
**VISO** is a dashboard I built to change that — not just charts, but a live SQL interface that lets anyone query the raw data directly from the browser. No download, no Excel, no waiting for a journalist to summarize it for you.
|
|
||||||
|
|
||||||
The design question was: what does transparency actually look like when you respect the user's intelligence? The answer I landed on was: give them the data and the tool to interrogate it, not just the conclusions.
|
|
||||||
|
|
||||||
Built with Python on the backend, React on the front, and a query engine that runs directly against the normalized dataset. Open source.
|
|
||||||
|
|
||||||
Civic tech is some of the most technically interesting work I've done — the constraint that the data is messy and the user is real makes everything harder and more honest.
|
|
||||||
|
|
||||||
🔗 github.com/rafapolo/viso
|
|
||||||
+22
-11
@@ -1,17 +1,28 @@
|
|||||||
# LinkedIn Posts — June/July 2026
|
# LinkedIn Posts
|
||||||
|
|
||||||
5 posts, every Wednesday. First post ever on LinkedIn.
|
Every Wednesday. The series and the Featured section ([featured.md](featured.md)) tell the same story: **Platform, Data & AI**, with security and financial-crime work for the Basel/Zürich/Zug targets in `applied.md`.
|
||||||
|
|
||||||
|
## Published
|
||||||
|
|
||||||
| # | File | Date | Project |
|
| # | File | Date | Project |
|
||||||
|---|------|------|---------|
|
|---|------|------|---------|
|
||||||
| 1 | [post-1-swissviz.md](post-1-swissviz.md) | Jun 3 | swissviz |
|
| 1 | [post-1-swissviz.md](published/post-1-swissviz.md) | Jun 3 | swissviz |
|
||||||
| 2 | [post-2-helvetiscan.md](post-2-helvetiscan.md) | Jun 10 | helvetiscan |
|
| 2 | [post-2-helvetiscan.md](published/post-2-helvetiscan.md) | Jun 10 | helvetiscan |
|
||||||
| 3 | [post-3-rodado-datative.md](post-3-rodado-datative.md) | Jun 17 | Rodado + datative |
|
|
||||||
| 4 | [post-4-tocador.md](post-4-tocador.md) | Jun 24 | Tocador |
|
|
||||||
| 5 | [post-5-viso.md](post-5-viso.md) | Jul 1 | VISO |
|
|
||||||
|
|
||||||
## Backlog (next candidates)
|
## Next
|
||||||
|
|
||||||
- **sumaria** — YouTube transcript summarizer with LLM. AI tooling angle.
|
| # | File | Date | Project | Angle |
|
||||||
- **VERAΞFIKO** — Awarded Web3 SmartContracts bridging fees into carbon credits. Swiss sustainability resonance.
|
|---|------|------|---------|-------|
|
||||||
- **Pegadas Tigrinho** — Corporate network of Brazilian gaming scam. Investigative dataviz storytelling.
|
| 3 | [post-3-rodado.md](post-3-rodado.md) | Oct 14 | Rodado | AI agents over 1,050 public tables via MCP |
|
||||||
|
| 4 | [post-4-brasilume.md](post-4-brasilume.md) | Oct 21 | brasilume | entity resolution: 21M addresses matched, in the browser |
|
||||||
|
| 5 | [post-5-fincrime.md](post-5-fincrime.md) | Oct 28 | FinCrime | ownership graphs for financial-crime investigation |
|
||||||
|
|
||||||
|
Posts 1 and 2 were Swiss data; 3–5 move to the data platform, the data-quality work under it, and what it is used for.
|
||||||
|
|
||||||
|
## Backlog
|
||||||
|
|
||||||
|
- **Vorcaros** — 263 partners → 8,088 companies they also control. Follow-up to FinCrime if it lands with finance/compliance people.
|
||||||
|
- **VISO** — federal deputies' expenses as a deputy↔company graph plus an in-browser SQL explorer (DuckDB WASM, Monaco). Civic-tech angle.
|
||||||
|
- **Tocador** — a static web player for music collections, pointed at any `.json.gz` catalogue, with a radio mode. Product angle.
|
||||||
|
- **VERAΞFIKO** — awarded Web3 smart contracts bridging fees into carbon credits. Swiss sustainability resonance.
|
||||||
|
- **Pegadas Tigrinho** — corporate network behind a Brazilian online-gambling scam. Investigative storytelling.
|
||||||
|
|||||||
+9
-1
@@ -35,7 +35,15 @@ const SIGNALS: [RegExp, number, string][] = [
|
|||||||
[/\bcloud\b|\baws\b|solutions?\s+architect/i, 3, "cloud/AWS"],
|
[/\bcloud\b|\baws\b|solutions?\s+architect/i, 3, "cloud/AWS"],
|
||||||
[/\bsecurity\b/i, 2, "security"],
|
[/\bsecurity\b/i, 2, "security"],
|
||||||
[/\bbackend\b|\bfull.?stack\b|\bapi\b|\bintegration\b/i, 2, "backend/API"],
|
[/\bbackend\b|\bfull.?stack\b|\bapi\b|\bintegration\b/i, 2, "backend/API"],
|
||||||
[/forward\s+deployed|\bfield\s+engineer|solutions?\s+engineer|customer\s+engineer/i, 4, "forward-deployed"],
|
// Agentic/LLM-agent work is the AI/LLM-integration edge from cv/polo.md —
|
||||||
|
// weighted on its own so "Agentic AI Engineer" clears minScore without a
|
||||||
|
// seniority word.
|
||||||
|
[/\bagentic\b/i, 4, "agentic"],
|
||||||
|
[/forward\s+deployed|\bfde\b|\bfield\s+engineer|solutions?\s+engineer|customer\s+engineer/i, 4, "forward-deployed"],
|
||||||
|
// Palantir Foundry is the platform FDE roles at pharma (Novartis Data42) are
|
||||||
|
// built on. "Foundry" alone is ambiguous (metal casting), so it only counts
|
||||||
|
// next to Palantir or an engineer/developer title.
|
||||||
|
[/\bpalantir\b|\bfoundry\s+(?:engineer|developer|architect)/i, 4, "Palantir Foundry"],
|
||||||
[/\bsoftware\s+engineer\b|\bdeveloper\b/i, 2, "software"],
|
[/\bsoftware\s+engineer\b|\bdeveloper\b/i, 2, "software"],
|
||||||
// Tech Lead is an explicit target role (see CLAUDE.md), but a bare title like
|
// Tech Lead is an explicit target role (see CLAUDE.md), but a bare title like
|
||||||
// "Technical Lead" or "Tech Lead" alone only ever hit the generic seniority
|
// "Technical Lead" or "Tech Lead" alone only ever hit the generic seniority
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
// Daily watch for the "Delivery Lead – Agentic Solutions" opening (Biomedical
|
||||||
|
// Research Digital & Data, likely Novartis) that MRP-Global posted on LinkedIn
|
||||||
|
// as job 4473851998 on 2026-10-03. Looks for the same role resurfacing in
|
||||||
|
// another form — another agency, a direct Novartis posting, a retitled
|
||||||
|
// repost — and reports when the original stops accepting applications.
|
||||||
|
//
|
||||||
|
// Runs from launchd (com.rafapolo.linkme-watch-delivery-lead, daily 10:00).
|
||||||
|
// Pushes to Telegram only when something new turns up; everything else goes to
|
||||||
|
// results/watch-delivery-lead.log. State lives in results/.watch-delivery-lead.json,
|
||||||
|
// so a hit is reported once. `--dry-run` prints instead of sending.
|
||||||
|
|
||||||
|
import { fetchDescription } from "../fetch.ts";
|
||||||
|
import { send, escapeHtml } from "../telegram.ts";
|
||||||
|
|
||||||
|
const ORIGINAL_ID = "4473851998";
|
||||||
|
const STATE_FILE = "results/.watch-delivery-lead.json";
|
||||||
|
const DRY_RUN = process.argv.includes("--dry-run");
|
||||||
|
|
||||||
|
const UA =
|
||||||
|
"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";
|
||||||
|
|
||||||
|
// Searches cast wide; SIGNATURE decides. A repost of this opening reuses the
|
||||||
|
// client's wording, so a candidate needs one client-specific phrase (STRONG)
|
||||||
|
// plus one role phrase. "agentic" + "delivery lead" alone matched Eraneos and
|
||||||
|
// AWS on the first dry run (2026-10-05) — generic consultancy wording.
|
||||||
|
const LINKEDIN_QUERIES = [
|
||||||
|
"delivery lead agentic",
|
||||||
|
"delivery lead AI data pharma",
|
||||||
|
"delivery lead biomedical",
|
||||||
|
"agentic solutions delivery",
|
||||||
|
"biomedical research digital data",
|
||||||
|
"delivery manager agentic",
|
||||||
|
];
|
||||||
|
const NOVARTIS_QUERIES = ["delivery lead", "delivery manager", "agentic", "biomedical research digital"];
|
||||||
|
|
||||||
|
const SIGNATURE: [string, RegExp][] = [
|
||||||
|
["agentic", /\bagentic\b/i],
|
||||||
|
["Biomedical Research", /biomedical\s+research/i],
|
||||||
|
["Digital & Data", /digital\s*(?:&|and)\s*data/i],
|
||||||
|
["delivery lead", /delivery\s+lead/i],
|
||||||
|
["Palantir", /\bpalantir\b/i],
|
||||||
|
["Databricks", /\bdatabricks\b/i],
|
||||||
|
["RWD", /\bRWD\b|real[-\s]world\s+data|clinical\s+trials?/i],
|
||||||
|
];
|
||||||
|
const STRONG = new Set(["Biomedical Research", "Digital & Data", "Palantir", "RWD"]);
|
||||||
|
const ROLE = new Set(["agentic", "delivery lead"]);
|
||||||
|
const isMatch = (m: string[]) => m.some((x) => STRONG.has(x)) && m.some((x) => ROLE.has(x));
|
||||||
|
const TITLE_RE = /deliver|agentic|programme?\s+lead|project\s+lead/i;
|
||||||
|
|
||||||
|
interface Hit { key: string; title: string; company: string; link: string; matched: string[] }
|
||||||
|
interface State { seen: string[]; reported: string[]; originalClosed?: boolean }
|
||||||
|
|
||||||
|
async function loadState(): Promise<State> {
|
||||||
|
const f = Bun.file(STATE_FILE);
|
||||||
|
return (await f.exists()) ? await f.json() : { seen: [], reported: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function signature(text: string): string[] {
|
||||||
|
return SIGNATURE.filter(([, re]) => re.test(text)).map(([name]) => name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const strip = (s: string) =>
|
||||||
|
s.replace(/<[^>]+>/g, " ").replace(/&/g, "&").replace(/'|'/g, "'").replace(/\s+/g, " ").trim();
|
||||||
|
|
||||||
|
async function get(url: string): Promise<string> {
|
||||||
|
const res = await fetch(url, { headers: { "User-Agent": UA } });
|
||||||
|
if (!res.ok) throw new Error(`${res.status} ${url}`);
|
||||||
|
return res.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchLinkedIn(state: State): Promise<Hit[]> {
|
||||||
|
const cards = new Map<string, { title: string; company: string }>();
|
||||||
|
for (const q of LINKEDIN_QUERIES) {
|
||||||
|
// Past month, Switzerland-wide. One page is enough: these queries are narrow.
|
||||||
|
const url = `https://www.linkedin.com/jobs-guest/jobs/api/seeMoreJobPostings/search?keywords=${encodeURIComponent(q)}&location=Switzerland&f_TPR=r2592000&start=0`;
|
||||||
|
try {
|
||||||
|
const html = await get(url);
|
||||||
|
for (const c of html.split("<li>").slice(1)) {
|
||||||
|
const id = c.match(/jobs\/view\/[^"]*?(\d{8,})/)?.[1];
|
||||||
|
const title = strip(c.match(/base-search-card__title[^>]*>([\s\S]*?)<\/h3>/)?.[1] ?? "");
|
||||||
|
const company = strip(c.match(/base-search-card__subtitle[^>]*>([\s\S]*?)<\/h4>/)?.[1] ?? "");
|
||||||
|
if (id && id !== ORIGINAL_ID && TITLE_RE.test(title)) cards.set(id, { title, company });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(` linkedin "${q}": ${e}`);
|
||||||
|
}
|
||||||
|
await Bun.sleep(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hits: Hit[] = [];
|
||||||
|
for (const [id, { title, company }] of cards) {
|
||||||
|
const key = `li-${id}`;
|
||||||
|
if (state.seen.includes(key)) continue;
|
||||||
|
const desc = await fetchDescription(id);
|
||||||
|
if (desc === null) continue; // unreadable — retry tomorrow rather than mark seen
|
||||||
|
state.seen.push(key);
|
||||||
|
const matched = signature(`${title} ${desc}`);
|
||||||
|
console.log(` li ${id} ${title} @ ${company}: [${matched.join(", ")}]`);
|
||||||
|
if (isMatch(matched)) {
|
||||||
|
hits.push({ key, title, company, link: `https://www.linkedin.com/jobs/view/${id}/`, matched });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchNovartis(state: State): Promise<Hit[]> {
|
||||||
|
const found = new Map<string, { title: string; path: string }>();
|
||||||
|
for (const q of NOVARTIS_QUERIES) {
|
||||||
|
const url = `https://www.novartis.com/careers/career-search?search_api_fulltext=${encodeURIComponent(q)}&country%5B%5D=LOC_CH`;
|
||||||
|
try {
|
||||||
|
const html = await get(url);
|
||||||
|
for (const m of html.matchAll(/href="(\/careers\/career-search\/job\/details\/(req-\d+)[^"]*)"[^>]*>([\s\S]*?)<\/a>/g)) {
|
||||||
|
const title = strip(m[3]);
|
||||||
|
if (TITLE_RE.test(title)) found.set(m[2], { title, path: m[1] });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(` novartis "${q}": ${e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hits: Hit[] = [];
|
||||||
|
for (const [req, { title, path }] of found) {
|
||||||
|
const key = `nv-${req}`;
|
||||||
|
if (state.seen.includes(key)) continue;
|
||||||
|
const link = `https://www.novartis.com${path}`;
|
||||||
|
let desc: string;
|
||||||
|
try {
|
||||||
|
desc = strip(await get(link));
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
state.seen.push(key);
|
||||||
|
const matched = signature(`${title} ${desc}`);
|
||||||
|
console.log(` nv ${req} ${title}: [${matched.join(", ")}]`);
|
||||||
|
if (isMatch(matched)) hits.push({ key, title, company: "Novartis", link, matched });
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function originalClosed(): Promise<boolean | null> {
|
||||||
|
try {
|
||||||
|
const html = await get(`https://www.linkedin.com/jobs/view/${ORIGINAL_ID}/`);
|
||||||
|
return /no longer accepting applications/i.test(html);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = await loadState();
|
||||||
|
console.log(`[${new Date().toISOString()}] watch-delivery-lead`);
|
||||||
|
|
||||||
|
const hits = [...(await searchLinkedIn(state)), ...(await searchNovartis(state))].filter(
|
||||||
|
(h) => !state.reported.includes(h.key),
|
||||||
|
);
|
||||||
|
const closed = await originalClosed();
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const h of hits) {
|
||||||
|
lines.push(
|
||||||
|
`• <b>${escapeHtml(h.title)}</b> — ${escapeHtml(h.company)}\n ${h.link}\n matched: ${escapeHtml(h.matched.join(", "))}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (closed === true && !state.originalClosed) {
|
||||||
|
lines.push(`• The original MRP-Global posting (${ORIGINAL_ID}) is no longer accepting applications.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lines.length) {
|
||||||
|
const msg = `🔎 <b>Delivery Lead – Agentic Solutions watch</b>\n\n${lines.join("\n\n")}`;
|
||||||
|
if (DRY_RUN) console.log(msg);
|
||||||
|
else await send(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only mark as reported after a successful send, same rule as daily.ts.
|
||||||
|
state.reported.push(...hits.map((h) => h.key));
|
||||||
|
if (closed === true) state.originalClosed = true;
|
||||||
|
if (!DRY_RUN) await Bun.write(STATE_FILE, JSON.stringify(state, null, 2));
|
||||||
|
console.log(` ${hits.length} new hit(s), original closed: ${closed}`);
|
||||||
Executable
+421
@@ -0,0 +1,421 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Periodic compromise/change monitor for this host.
|
||||||
|
#
|
||||||
|
# Snapshots a set of security-relevant system facts (kernel/boot id, accounts and groups,
|
||||||
|
# UID-0 accounts, SSH authorized keys, sudoers, PAM, sshd config + effective policy, cron/at,
|
||||||
|
# enabled systemd units, shell startup files, dynamic-linker preload/config, apt sources,
|
||||||
|
# name resolution, listening TCP/UDP sockets, docker containers and their security
|
||||||
|
# posture, mounted block devices, SUID/SGID files with hashes, file capabilities, loaded
|
||||||
|
# kernel modules, host-level firewall policy, nftables table list, critical binary hashes,
|
||||||
|
# and the cumulative set of accepted SSH logins), diffs against the previous run, and pushes
|
||||||
|
# a Telegram alert (with a "[section]" label per changed block) when anything changed. Silent
|
||||||
|
# (no alert, no output) when nothing changed. First run just seeds the baseline.
|
||||||
|
#
|
||||||
|
# Deliberately NOT snapshotted, because they'd churn constantly on this host and just be noise:
|
||||||
|
# full process list/cmdlines (PIDs and cron-spawned procs change every tick), established
|
||||||
|
# network connections (ephemeral client churn), the full Docker-managed firewall ruleset and
|
||||||
|
# nftables rule bodies (rewritten on every container start/stop and on every fail2ban ban —
|
||||||
|
# see the scoped host-level-only checks instead), SUID files under the container image stores
|
||||||
|
# (/var/lib/{docker,containerd,haloy} — image layers carry their own su/mount/passwd and the
|
||||||
|
# layer directory names change on every image pull), swap (active devices/usage flap with
|
||||||
|
# ordinary memory pressure, not a security signal), a generic "recent /etc mtimes" catch-all
|
||||||
|
# (certbot renewals, resolv.conf, apt all touch /etc routinely), and /tmp, /dev/shm, /var/tmp
|
||||||
|
# contents (this host runs ad-hoc dev/test work there routinely — dropped after it turned out
|
||||||
|
# to be pure noise, see git history).
|
||||||
|
#
|
||||||
|
# Separately (not part of the diff above — disk % changes constantly and would spam it),
|
||||||
|
# checks each real filesystem's usage against a threshold and alerts once on crossing it, with
|
||||||
|
# hysteresis so it doesn't flap an alert every tick while hovering right at the line.
|
||||||
|
#
|
||||||
|
# Install: run once manually to seed the baseline, then drop into cron (e.g. /etc/cron.d,
|
||||||
|
# every 15 min). All state lives under $STATE_DIR; nothing else on the host is touched.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
# Cron gives system crontabs a bare PATH (/usr/bin:/bin), so tools in /usr/sbin (iptables,
|
||||||
|
# nft, getcap...) silently vanish there while working fine in an interactive root shell —
|
||||||
|
# which made cron and manual runs produce *different* snapshots and fire false alerts (an
|
||||||
|
# empty section that should have had content, etc). Pin PATH so every run sees the same tools.
|
||||||
|
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
|
# Likewise pin the locale: sort(1) collation and date(1) names are locale-dependent, so an
|
||||||
|
# interactive UTF-8 root shell and a locale-less cron run would otherwise order/format the
|
||||||
|
# snapshot differently and diff against each other.
|
||||||
|
export LC_ALL=C
|
||||||
|
umask 077 # state files hold key fingerprints and source IPs
|
||||||
|
|
||||||
|
# Telegram credentials live outside the script (it's in a public repo): a root-only env file
|
||||||
|
# with TELEGRAM_BOT=... and TELEGRAM_CHAT_ID=... lines. Fail loudly if it's missing rather than
|
||||||
|
# running a monitor that silently can't alert.
|
||||||
|
TELEGRAM_ENV="${TELEGRAM_ENV:-/root/.security-monitor/telegram.env}"
|
||||||
|
if [ ! -r "$TELEGRAM_ENV" ]; then
|
||||||
|
echo "ERROR: $TELEGRAM_ENV missing or unreadable — no way to send alerts" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
. "$TELEGRAM_ENV"
|
||||||
|
: "${TELEGRAM_BOT:?not set in $TELEGRAM_ENV}" "${TELEGRAM_CHAT_ID:?not set in $TELEGRAM_ENV}"
|
||||||
|
|
||||||
|
DISK_ALERT_PCT="${DISK_ALERT_PCT:-90}" # alert once usage reaches this
|
||||||
|
DISK_RECOVER_PCT="${DISK_RECOVER_PCT:-85}" # ...and don't re-arm until it drops back below this (hysteresis)
|
||||||
|
|
||||||
|
STATE_DIR=/root/.security-monitor
|
||||||
|
CURRENT="$STATE_DIR/current.snapshot"
|
||||||
|
PREVIOUS="$STATE_DIR/previous.snapshot"
|
||||||
|
DISK_STATE="$STATE_DIR/disk_alert_state"
|
||||||
|
SSH_LOGINS="$STATE_DIR/ssh_logins" # cumulative user@ip set, append-only
|
||||||
|
SSH_CURSOR="$STATE_DIR/ssh_cursor" # journal cursor so we only read new entries
|
||||||
|
LOCK="$STATE_DIR/lock"
|
||||||
|
mkdir -p "$STATE_DIR"
|
||||||
|
chmod 700 "$STATE_DIR"
|
||||||
|
touch "$DISK_STATE" "$SSH_LOGINS"
|
||||||
|
# umask only governs newly created files; chmod explicitly so state written by an older
|
||||||
|
# version of this script (or by a cp onto an existing file) is tightened too.
|
||||||
|
chmod 600 "$DISK_STATE" "$SSH_LOGINS" "$CURRENT" "$PREVIOUS" 2>/dev/null
|
||||||
|
|
||||||
|
# A slow run (big journal, hung docker) must not overlap the next cron tick and race on state.
|
||||||
|
exec 9>"$LOCK"
|
||||||
|
if ! flock -n 9; then
|
||||||
|
echo "another run is in progress, skipping this tick"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
TMPSNAP="$CURRENT.tmp.$$"
|
||||||
|
trap 'rm -f "$TMPSNAP"' EXIT
|
||||||
|
|
||||||
|
# Print "<path> <sha256>" for each argument that is a regular file. Unmatched globs arrive
|
||||||
|
# literally and are filtered out by the -f test; IFS-safe and quoting-safe throughout.
|
||||||
|
hash_files() {
|
||||||
|
local f
|
||||||
|
for f in "$@"; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
echo "$f $(sha256sum "$f" 2>/dev/null | cut -d' ' -f1)"
|
||||||
|
done | sort
|
||||||
|
}
|
||||||
|
|
||||||
|
# Listening sockets, normalised: pid/fd stripped (they change on every restart) and duplicate
|
||||||
|
# process entries collapsed (nginx reports one entry per worker, so a worker-count change from
|
||||||
|
# a routine reload would otherwise look like a socket change).
|
||||||
|
# UDP sockets of IGNORE_UDP_PROCS are dropped: helvetiscan (a known, trusted scanner) opens and
|
||||||
|
# closes dozens of random-port UDP sockets per second, so every tick alerted on lines like
|
||||||
|
# "UNCONN *:13786 helvetiscan". Its TCP listeners are still tracked. Ownerless UDP sockets on a
|
||||||
|
# port >= 1024 are dropped too: they're the same scanner's sockets caught mid-teardown, already
|
||||||
|
# detached from the process (sequential inodes, a new port every second). Ownerless sockets on
|
||||||
|
# privileged ports (e.g. the DHCP client on :68) stay in the snapshot.
|
||||||
|
IGNORE_UDP_PROCS="${IGNORE_UDP_PROCS:-helvetiscan}"
|
||||||
|
listening_sockets() {
|
||||||
|
local flag="$1"
|
||||||
|
ss -"$flag"lnp 2>/dev/null | tail -n +2 | awk -v flag="$flag" -v ignprocs="$IGNORE_UDP_PROCS" '
|
||||||
|
BEGIN { n = split(ignprocs, ip, /[ ,]+/); for (i = 1; i <= n; i++) if (ip[i] != "") ign[ip[i]] = 1 }
|
||||||
|
{
|
||||||
|
proc = $6
|
||||||
|
gsub(/,?(pid|fd)=[0-9]+/, "", proc)
|
||||||
|
gsub(/^users:\(|\)$/, "", proc)
|
||||||
|
n = split(proc, parts, /\),\(/)
|
||||||
|
out = ""
|
||||||
|
delete seen
|
||||||
|
for (i = 1; i <= n; i++) {
|
||||||
|
gsub(/[()"]/, "", parts[i])
|
||||||
|
if (parts[i] != "" && !(parts[i] in seen)) {
|
||||||
|
seen[parts[i]] = 1
|
||||||
|
out = (out == "" ? parts[i] : out "," parts[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (flag == "u") {
|
||||||
|
port = $4; sub(/.*:/, "", port)
|
||||||
|
if (out == "" && port + 0 >= 1024) next
|
||||||
|
n = split(out, names, ",")
|
||||||
|
for (i = 1; i <= n; i++) if (names[i] in ign) next
|
||||||
|
}
|
||||||
|
print $1, $4, out
|
||||||
|
}' | sort -u
|
||||||
|
}
|
||||||
|
|
||||||
|
# Accepted SSH logins, accumulated into a persistent set rather than re-derived from a rolling
|
||||||
|
# journal window. Two reasons: (1) a rolling window makes old-but-legitimate source IPs *fall
|
||||||
|
# out* of the snapshot as they age, which shows up as an alarming "-" line for something that
|
||||||
|
# never actually changed; (2) scanning 90 days of a multi-GB journal every 15 minutes cost
|
||||||
|
# ~10s per run — with a saved cursor we only read entries added since the last run.
|
||||||
|
collect_ssh_logins() {
|
||||||
|
local raw="" rc newcur
|
||||||
|
if [ -s "$SSH_CURSOR" ]; then
|
||||||
|
raw=$(journalctl -u ssh --after-cursor "$(cat "$SSH_CURSOR")" --no-pager --show-cursor 2>/dev/null)
|
||||||
|
rc=$?
|
||||||
|
if [ "$rc" -ne 0 ]; then # cursor no longer in the journal (rotated) — reseed
|
||||||
|
raw=""
|
||||||
|
rm -f "$SSH_CURSOR"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ ! -s "$SSH_CURSOR" ]; then
|
||||||
|
raw=$(journalctl -u ssh --since "-90 days" --no-pager --show-cursor 2>/dev/null)
|
||||||
|
fi
|
||||||
|
if [ -n "$raw" ]; then
|
||||||
|
printf '%s\n' "$raw" \
|
||||||
|
| grep -oE 'Accepted [a-z-]+ for [^ ]+ from [0-9a-fA-F.:]+' \
|
||||||
|
| awk '{print $4"@"$6}' >> "$SSH_LOGINS"
|
||||||
|
newcur=$(printf '%s\n' "$raw" | sed -n 's/^-- cursor: //p' | tail -1)
|
||||||
|
[ -n "$newcur" ] && printf '%s\n' "$newcur" > "$SSH_CURSOR"
|
||||||
|
fi
|
||||||
|
[ -s "$SSH_LOGINS" ] && sort -u "$SSH_LOGINS" -o "$SSH_LOGINS"
|
||||||
|
cat "$SSH_LOGINS" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot() {
|
||||||
|
echo "## kernel and boot"
|
||||||
|
uname -srvm
|
||||||
|
echo "boot_id $(cat /proc/sys/kernel/random/boot_id 2>/dev/null)"
|
||||||
|
|
||||||
|
echo "## users (login shells)"
|
||||||
|
awk -F: '$7 !~ /nologin|false/ {print $1":"$3":"$6":"$7}' /etc/passwd | sort
|
||||||
|
|
||||||
|
echo "## uid 0 accounts"
|
||||||
|
awk -F: '$3 == 0 {print $1":"$3":"$6":"$7}' /etc/passwd | sort
|
||||||
|
|
||||||
|
echo "## all accounts (name:uid:gid:shell)"
|
||||||
|
awk -F: '{print $1":"$3":"$4":"$7}' /etc/passwd | sort
|
||||||
|
|
||||||
|
echo "## groups (name:gid:members)"
|
||||||
|
getent group 2>/dev/null | awk -F: '{print $1":"$3":"$4}' | sort
|
||||||
|
|
||||||
|
echo "## credential db hashes"
|
||||||
|
hash_files /etc/shadow /etc/gshadow /etc/securetty
|
||||||
|
|
||||||
|
echo "## ssh authorized_keys"
|
||||||
|
for f in /root/.ssh/authorized_keys /root/.ssh/authorized_keys2 \
|
||||||
|
/home/*/.ssh/authorized_keys /home/*/.ssh/authorized_keys2; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
echo "-- $f --"
|
||||||
|
awk '{print $1, $NF}' "$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "## sudoers (path sha256)"
|
||||||
|
hash_files /etc/sudoers /etc/sudoers.d/*
|
||||||
|
|
||||||
|
echo "## pam.d configs (path sha256)"
|
||||||
|
hash_files /etc/pam.d/*
|
||||||
|
|
||||||
|
echo "## ssh daemon config (path sha256)"
|
||||||
|
hash_files /etc/ssh/sshd_config /etc/ssh/sshd_config.d/*
|
||||||
|
|
||||||
|
echo "## sshd effective policy (resolved by sshd -T, catches Include changes)"
|
||||||
|
sshd -T 2>/dev/null | grep -E '^(permitrootlogin|passwordauthentication|permitemptypasswords|pubkeyauthentication|kbdinteractiveauthentication|usepam|x11forwarding|allowtcpforwarding|gatewayports|permittunnel|port|listenaddress|authorizedkeysfile|authorizedkeyscommand|allowusers|allowgroups|denyusers|denygroups|forcecommand|permituserenvironment|maxauthtries|logingracetime)\b' | sort
|
||||||
|
|
||||||
|
echo "## cron files (path sha256)"
|
||||||
|
hash_files /etc/crontab /etc/cron.d/* /etc/cron.daily/* /etc/cron.hourly/* \
|
||||||
|
/etc/cron.weekly/* /etc/cron.monthly/* /var/spool/cron/crontabs/*
|
||||||
|
echo "-- root crontab --"
|
||||||
|
crontab -l -u root 2>/dev/null | sha256sum
|
||||||
|
echo "-- at jobs --"
|
||||||
|
find /var/spool/cron/atjobs -type f 2>/dev/null | sort
|
||||||
|
|
||||||
|
echo "## systemd enabled services/timers (unit path sha256)"
|
||||||
|
systemctl list-unit-files --type=service,timer --state=enabled --no-legend 2>/dev/null \
|
||||||
|
| awk '{print $1}' | sort -u | while IFS= read -r u; do
|
||||||
|
f=$(systemctl show -p FragmentPath --value "$u" 2>/dev/null)
|
||||||
|
if [ -n "$f" ] && [ -f "$f" ]; then
|
||||||
|
echo "$u $(sha256sum "$f" 2>/dev/null | cut -d' ' -f1)"
|
||||||
|
else
|
||||||
|
echo "$u (no fragment)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "## shell startup files (path sha256)"
|
||||||
|
hash_files /etc/profile /etc/profile.d/* /etc/bash.bashrc /etc/environment \
|
||||||
|
/root/.bashrc /root/.profile /root/.bash_profile /root/.bash_login \
|
||||||
|
/root/.bash_logout /home/*/.bashrc /home/*/.profile /home/*/.bash_profile
|
||||||
|
|
||||||
|
echo "## dynamic linker preload/config (path sha256)"
|
||||||
|
if [ -e /etc/ld.so.preload ]; then
|
||||||
|
hash_files /etc/ld.so.preload
|
||||||
|
else
|
||||||
|
echo "/etc/ld.so.preload ABSENT" # its appearance is a classic rootkit signal
|
||||||
|
fi
|
||||||
|
hash_files /etc/ld.so.conf /etc/ld.so.conf.d/*
|
||||||
|
|
||||||
|
echo "## apt sources and keyrings (path sha256)"
|
||||||
|
hash_files /etc/apt/sources.list /etc/apt/sources.list.d/* \
|
||||||
|
/etc/apt/trusted.gpg.d/* /etc/apt/keyrings/* /etc/apt/apt.conf.d/*
|
||||||
|
|
||||||
|
echo "## name resolution (path sha256)"
|
||||||
|
hash_files /etc/hosts /etc/resolv.conf /etc/nsswitch.conf /etc/host.conf
|
||||||
|
|
||||||
|
echo "## listening tcp sockets"
|
||||||
|
listening_sockets t
|
||||||
|
|
||||||
|
echo "## listening udp sockets"
|
||||||
|
listening_sockets u
|
||||||
|
|
||||||
|
echo "## docker containers (name image)"
|
||||||
|
docker ps -a --format "{{.Names}} {{.Image}}" 2>/dev/null | sort
|
||||||
|
|
||||||
|
echo "## docker security posture (haloy deploy id normalised, so redeploys are silent)"
|
||||||
|
local ids
|
||||||
|
ids=$(docker ps -q 2>/dev/null)
|
||||||
|
if [ -n "$ids" ]; then
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
docker inspect --format '{{.Name}} privileged={{.HostConfig.Privileged}} net={{.HostConfig.NetworkMode}} pid={{.HostConfig.PidMode}} user={{.Config.User}} caps={{.HostConfig.CapAdd}} secopt={{.HostConfig.SecurityOpt}} binds={{.HostConfig.Binds}}' $ids 2>/dev/null \
|
||||||
|
| sed -E 's#^/##; s/^([^ ]+)-[0-9a-z]{26} /\1-<deploy> /' | sort
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "## mounted filesystems (real block devices only — excludes overlay/tmpfs/container churn)"
|
||||||
|
mount | grep -E '^/dev/' | awk '{print $1, $3, $5, $6}' | sort
|
||||||
|
|
||||||
|
echo "## suid/sgid regular files (path sha256)"
|
||||||
|
# -xdev keeps us off other filesystems; the -prune list keeps us out of the container image
|
||||||
|
# stores, which live on / and whose layer dirs (each carrying its own suid su/mount/passwd)
|
||||||
|
# are created and destroyed on every image pull — i.e. on every haloy deploy.
|
||||||
|
find / -xdev \( -path /var/lib/docker -o -path /var/lib/containerd -o -path /var/lib/haloy \) -prune \
|
||||||
|
-o -type f \( -perm -4000 -o -perm -2000 \) -print 2>/dev/null \
|
||||||
|
| sort | while IFS= read -r f; do
|
||||||
|
echo "$f $(sha256sum "$f" 2>/dev/null | cut -d' ' -f1)"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "## file capabilities (suid-equivalent privilege grants)"
|
||||||
|
getcap -r /usr /opt /srv /etc /root /home 2>/dev/null | sort
|
||||||
|
|
||||||
|
echo "## kernel modules loaded"
|
||||||
|
lsmod | awk 'NR>1{print $1}' | sort
|
||||||
|
|
||||||
|
echo "## firewall base-chain policies/rules (host-level only — DOCKER-managed nested chains excluded, they churn on every container start/stop)"
|
||||||
|
for c in INPUT OUTPUT FORWARD; do
|
||||||
|
iptables -S "$c" 2>/dev/null | sed 's/^/v4 /'
|
||||||
|
ip6tables -S "$c" 2>/dev/null | sed 's/^/v6 /'
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "## nftables tables (names only — f2b rule bodies churn on every ban)"
|
||||||
|
nft list tables 2>/dev/null | sort
|
||||||
|
|
||||||
|
echo "## critical binary hashes"
|
||||||
|
sha256sum /usr/bin/sudo /usr/bin/su /usr/sbin/sshd /bin/bash /bin/sh /usr/bin/docker \
|
||||||
|
/usr/bin/dockerd /usr/bin/containerd /usr/bin/runc /usr/bin/systemctl \
|
||||||
|
/usr/lib/systemd/systemd /usr/bin/curl /usr/bin/wget /usr/sbin/nft /usr/sbin/iptables \
|
||||||
|
/usr/local/bin/haloyd /root/.local/bin/haloy 2>/dev/null | awk '{print $2, $1}' | sort
|
||||||
|
|
||||||
|
echo "## ssh new logins (user@source — appended once seen, never removed)"
|
||||||
|
collect_ssh_logins
|
||||||
|
|
||||||
|
# Sentinel: proves the snapshot ran to completion. Without it, a run killed mid-snapshot
|
||||||
|
# (OOM, cron timeout, hung docker) would write a truncated file, diff it as a huge fake
|
||||||
|
# "everything disappeared" alert, and then poison the baseline with the truncated version.
|
||||||
|
echo "## end of snapshot"
|
||||||
|
}
|
||||||
|
|
||||||
|
send_telegram() {
|
||||||
|
local resp attempt
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
resp=$(curl -s -m 20 -X POST "https://api.telegram.org/bot${TELEGRAM_BOT}/sendMessage" \
|
||||||
|
--data-urlencode "chat_id=${TELEGRAM_CHAT_ID}" \
|
||||||
|
--data-urlencode "text=$1" \
|
||||||
|
--data-urlencode "disable_web_page_preview=true" 2>/dev/null)
|
||||||
|
case "$resp" in *'"ok":true'*) return 0 ;; esac
|
||||||
|
[ "$attempt" -lt 3 ] && sleep 5
|
||||||
|
done
|
||||||
|
echo "WARNING: telegram send failed after 3 attempts; last response: ${resp:0:200}" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
check_disk_usage() {
|
||||||
|
local host; host=$(hostname)
|
||||||
|
# -P for POSIX single-line-per-fs output; skip pseudo filesystems that don't reflect real
|
||||||
|
# disk (tmpfs/devtmpfs/overlay churn with container lifecycle and mean nothing here).
|
||||||
|
df -P -x tmpfs -x devtmpfs -x overlay -x squashfs 2>/dev/null | tail -n +2 | while read -r _fs _blocks _used avail pct mount; do
|
||||||
|
local pct_num=${pct%\%}
|
||||||
|
case "$pct_num" in ''|*[!0-9]*) continue ;; esac # skip unparseable lines defensively
|
||||||
|
local prev
|
||||||
|
prev=$(grep "^${mount}:" "$DISK_STATE" 2>/dev/null | tail -1 | cut -d: -f2)
|
||||||
|
# Only record the new state once the alert actually went out, so a failed send is
|
||||||
|
# retried on the next tick instead of being silently swallowed.
|
||||||
|
if [ "$pct_num" -ge "$DISK_ALERT_PCT" ] && [ "$prev" != "ALERTED" ]; then
|
||||||
|
if send_telegram "🔴 disk-monitor: ${mount} on ${host} is ${pct_num}% full (${avail}KB free) — crossed ${DISK_ALERT_PCT}% threshold"; then
|
||||||
|
sed -i "\#^${mount}:#d" "$DISK_STATE"
|
||||||
|
echo "${mount}:ALERTED" >> "$DISK_STATE"
|
||||||
|
fi
|
||||||
|
elif [ "$pct_num" -le "$DISK_RECOVER_PCT" ] && [ "$prev" = "ALERTED" ]; then
|
||||||
|
if send_telegram "🟢 disk-monitor: ${mount} on ${host} recovered to ${pct_num}% full"; then
|
||||||
|
sed -i "\#^${mount}:#d" "$DISK_STATE"
|
||||||
|
echo "${mount}:OK" >> "$DISK_STATE"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot > "$TMPSNAP" 2>/dev/null
|
||||||
|
if [ "$(tail -n 1 "$TMPSNAP" 2>/dev/null)" != "## end of snapshot" ]; then
|
||||||
|
echo "ERROR: snapshot did not complete (no sentinel) — leaving state untouched" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mv -f "$TMPSNAP" "$CURRENT"
|
||||||
|
|
||||||
|
check_disk_usage
|
||||||
|
|
||||||
|
if [ ! -f "$PREVIOUS" ]; then
|
||||||
|
cp "$CURRENT" "$PREVIOUS"
|
||||||
|
echo "baseline seeded, no previous snapshot to compare against"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -U is deliberately larger than the snapshot can plausibly get: with one all-encompassing
|
||||||
|
# hunk, every "## section" header is present as a context line, which is what lets the awk
|
||||||
|
# below attribute each changed line to its section.
|
||||||
|
DIFF=$(diff -U 100000 "$PREVIOUS" "$CURRENT" 2>/dev/null || true)
|
||||||
|
if [ -n "$DIFF" ]; then
|
||||||
|
HOST=$(hostname)
|
||||||
|
# Label the first changed line under each section — e.g. "[docker containers (name image)]"
|
||||||
|
# — instead of dumping bare +/- lines with no indication of what actually changed.
|
||||||
|
# NB: the ---/+++ skip is anchored to the first two lines on purpose. The snapshot itself
|
||||||
|
# contains lines like "-- /root/.ssh/authorized_keys --", and when such a line is *removed*
|
||||||
|
# the diff renders it as "--- /root/.ssh/authorized_keys --" — an unanchored /^--- / rule
|
||||||
|
# silently swallowed exactly the kind of event this script exists to report.
|
||||||
|
CHANGED=$(echo "$DIFF" | awk '
|
||||||
|
NR <= 2 && /^(--- |\+\+\+ )/ { next }
|
||||||
|
/^@@/ { next }
|
||||||
|
{
|
||||||
|
marker = substr($0, 1, 1)
|
||||||
|
text = substr($0, 2)
|
||||||
|
}
|
||||||
|
text ~ /^## / {
|
||||||
|
section = substr(text, 4)
|
||||||
|
if (marker == "+" || marker == "-") {
|
||||||
|
print "[" section "]"
|
||||||
|
prevsection = section
|
||||||
|
print $0
|
||||||
|
}
|
||||||
|
next
|
||||||
|
}
|
||||||
|
marker == "+" || marker == "-" {
|
||||||
|
if (section != prevsection) { print "[" section "]"; prevsection = section }
|
||||||
|
print $0
|
||||||
|
}
|
||||||
|
')
|
||||||
|
# Summarise up front: the body gets truncated to fit Telegram's limit, and a burst of
|
||||||
|
# changes in one section must not hide that a second, quieter section also changed.
|
||||||
|
SECTIONS=$(printf '%s\n' "$CHANGED" | sed -n 's/^\[\(.*\)\]$/\1/p' \
|
||||||
|
| awk '{ printf "%s%s", (NR > 1 ? "; " : ""), $0 } END { if (NR) print "" }')
|
||||||
|
N_ADD=$(printf '%s\n' "$CHANGED" | grep -c '^+' || true)
|
||||||
|
N_DEL=$(printf '%s\n' "$CHANGED" | grep -c '^-' || true)
|
||||||
|
MSG="⚠️ security-monitor: change detected on ${HOST}
|
||||||
|
$(date "+%a %Y-%m-%d %H:%M:%S %Z")
|
||||||
|
sections: ${SECTIONS}
|
||||||
|
(+${N_ADD} / -${N_DEL} lines)
|
||||||
|
|
||||||
|
${CHANGED}"
|
||||||
|
if [ "${#MSG}" -gt 3900 ]; then
|
||||||
|
MSG="${MSG:0:3880}
|
||||||
|
… [truncated]"
|
||||||
|
fi
|
||||||
|
if send_telegram "$MSG"; then
|
||||||
|
# Commit the new baseline *before* writing the diff to stdout: if stdout is a pipe
|
||||||
|
# that has already closed (someone running this by hand through `head`), the echo
|
||||||
|
# takes a SIGPIPE and the script dies — and without this ordering it would die
|
||||||
|
# holding the stale baseline and re-alert the identical change on the next run.
|
||||||
|
cp "$CURRENT" "$PREVIOUS"
|
||||||
|
echo "CHANGE DETECTED — alert sent"
|
||||||
|
echo "$CHANGED"
|
||||||
|
else
|
||||||
|
# Keep the old baseline so the very next run re-detects and re-reports this change,
|
||||||
|
# rather than losing it forever to a transient network failure.
|
||||||
|
echo "CHANGE DETECTED — alert FAILED to send; keeping previous baseline to retry next run" >&2
|
||||||
|
echo "$CHANGED"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "no change"
|
||||||
|
cp "$CURRENT" "$PREVIOUS"
|
||||||
|
fi
|
||||||
@@ -11,6 +11,32 @@ Scope: `linkme/cv/`, `linkme/motivation*.md`, `rafapolo/` portfolio + `rafapolo/
|
|||||||
- [ ] Reconsider German-first corporates (Coop, Valora, Helvetia) while at B1 — screening
|
- [ ] Reconsider German-first corporates (Coop, Valora, Helvetia) while at B1 — screening
|
||||||
failure precedes any engineering assessment.
|
failure precedes any engineering assessment.
|
||||||
|
|
||||||
|
## Referência da Swisscom (aberto, ação do Rafael)
|
||||||
|
|
||||||
|
- [ ] Pedir à Swisscom uma confirmação de emprego (Arbeitsbestätigung, Art. 330a al. 2 CO),
|
||||||
|
só com função e datas, sem avaliação. Fallback caso a correção da carta de março de
|
||||||
|
2025 ("moderate results") seja recusada: a confirmação pesa menos que a carta, mas
|
||||||
|
também chama atenção.
|
||||||
|
- Para: Swisscom (Switzerland) Ltd, Group Human Resources, HR Advisory, CH-3050 Berne
|
||||||
|
- Dados: DevOps Engineer III, Transport IP Network, Zürich, 01/03/2024 a 28/02/2025
|
||||||
|
- Ao receber: salvar em `rafapolo/job/credentials/references/` e usar no dossiê no
|
||||||
|
lugar da carta.
|
||||||
|
|
||||||
|
Rascunho:
|
||||||
|
|
||||||
|
> Dear HR Advisory team,
|
||||||
|
>
|
||||||
|
> I was employed by Swisscom (Switzerland) Ltd from March 1, 2024 until
|
||||||
|
> February 28, 2025 as DevOps Engineer III in the Transport IP Network unit in Zürich.
|
||||||
|
>
|
||||||
|
> Under Art. 330a para. 2 CO, I would like to request a confirmation of employment
|
||||||
|
> stating only the nature and duration of my employment, without any assessment of
|
||||||
|
> performance or conduct.
|
||||||
|
>
|
||||||
|
> Thank you in advance.
|
||||||
|
>
|
||||||
|
> Rafael Gomes de Oliveira Polo
|
||||||
|
|
||||||
## Not changing
|
## Not changing
|
||||||
|
|
||||||
Scraper is fine — `fetch.ts`, filter pipeline, `index.ts` all working. No changes needed.
|
Scraper is fine — `fetch.ts`, filter pipeline, `index.ts` all working. No changes needed.
|
||||||
|
|||||||
Reference in New Issue
Block a user