Compare commits

..
14 Commits
Author SHA1 Message Date
rodado 5a2d053ca7 docs: track swisscom employment confirmation request 2026-11-03 15:35:01 +01:00
rodado b05bddb73d feat: add data-jobs.ch and arbeitnow.ch sources 2026-11-03 15:35:01 +01:00
rodado b5cf8931ea feat: group luzern jobs in daily digest 2026-11-03 15:35:01 +01:00
rodado 73840cad04 feat: add frontend and data-visualisation search family 2026-11-03 15:35:01 +01:00
rodado aeb16de5ed fix: load security monitor telegram credentials from root-only env file 2026-10-15 10:03:07 +02:00
rodado 63f84b6d04 fix: ignore helvetiscan and ownerless ephemeral udp sockets in security monitor 2026-10-15 09:50:40 +02:00
rodado 94febd5001 chore: add bun lockfile 2026-10-06 15:17:17 +02:00
rodado 069b371be9 feat: add host security change monitor script 2026-10-06 15:17:17 +02:00
rodado d61e5b7615 docs: track submitted applications 2026-10-06 15:17:17 +02:00
rodado bac6555c10 docs: plan next linkedin posts and archive published ones 2026-10-06 15:17:17 +02:00
rodado e70ae1e932 feat: add startup.jobs as a switzerland-wide job source 2026-10-06 15:17:17 +02:00
rodado ab659008e6 feat: match agentic, llm, fde and palantir foundry roles 2026-10-06 15:17:17 +02:00
rodado 01dd3861b6 fix: parse minimally-quoted csv rows when loading jobs.csv 2026-10-06 15:17:16 +02:00
rodado 74f8df0b15 chore: move cv and job applications to private rafapolo/job, add delivery-lead watcher
cv/ and jobs/ become symlinks into ../rafapolo/job (gitignored there).
scripts/watch-delivery-lead.ts runs daily via launchd and alerts on Telegram
when the MRP-Global agentic Delivery Lead role resurfaces or closes.
2026-10-05 19:22:07 +02:00
34 changed files with 1245 additions and 898 deletions
+3 -5
View File
@@ -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
+19 -6
View File
@@ -21,7 +21,7 @@ Temp scripts: `bun run scripts/<name>-temp.ts` — never edit `config.ts` for on
``` ```
config.ts # search keywords and areas config.ts # search keywords and areas
fetch.ts # scraper, HTML parser, all filters fetch.ts # scraper, HTML parser, all filters
fetch-other.ts # extra sources (datacareer.ch, stellen-basel.ch, itjobs.ch, job-room.ch, Basel intl orgs, jobbasel.ch, englishjobsearch.ch, basel-jobs.ch, scicore.unibas.ch, swisscom.com, novartis.com, more-jobs.ch) fetch-other.ts # extra sources (datacareer.ch, stellen-basel.ch, itjobs.ch, job-room.ch, Basel intl orgs, jobbasel.ch, englishjobsearch.ch, basel-jobs.ch + 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,20 @@ 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, scicore.unibas.ch, swisscom.com, novartis.com, more-jobs.ch). Imported by `index.ts`. - **`fetch-other.ts`** — scrapes extra sources (datacareer.ch, stellen-basel.ch, itjobs.ch, job-room.ch, Basel international orgs, jobbasel.ch, englishjobsearch.ch, basel-jobs.ch + 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.com** (`fetchNovartisBasel`, added 2026-09-21) — Rafael has an internal contact at Novartis Basel who can refer him, so this source exists purely to catch a genuine software/engineering opening there, not to widen the CSV generally. Its career-search page (`novartis.com/careers/career-search?country[]=LOC_CH`) is a server-rendered Drupal view — no headless browser needed — but only at the **locale-less English path** (`/careers/career-search`); the `ch-de` German path's job-detail links 302 straight back to the search page for both locales when hit without a session, so detail pages only resolve through the English path. Pagination is 0-indexed (`page=0` implicit, `page=1` is the second page of 10) and the page's own "Showing N results" line gives the exact total, so the page-loop bound is computed rather than probed — the listing has also been observed to **repeat rows across pages within one sweep** (measured 2026-09-21: several titles appeared twice across 5 pages), so results are deduped by `req-<id>` before filtering, not trusted from the pager.
Novartis Basel's titling is almost entirely non-technical "Director, `<business function>`" roles (Portfolio Analytics, Therapeutic Area Strategy, Policy Communications, ...) plus device/hardware "Engineering" roles (autoinjector/drug-delivery hardware, not software) — the generic `SOFTWARE_TITLE_RE` used by every other source in this file matched **13 of 47** CH listings on title alone in an initial run, and every single one was a false positive (no engineering-team role among them). `NOVARTIS_TITLE_RE` replaces it here: it requires an explicit software/IT engineering job-family term (`software engineer`, `platform engineer`, `data engineer`, `devops`, `sre`, `solutions architect`, ...), not just a bare seniority or business-function word. `BIO_DOMAIN_RE` and job-room.ch's `PHARMA_ROLE_RE` still run on top to catch domain-science titles the stricter regex wouldn't otherwise need to worry about. Narrowed to Basel/Muttenz specifically (`NOVARTIS_BASEL_RE`) — the CH listing also includes Stein (Aargau), a different commute the contact couldn't refer into. As of 2026-09-21 this source keeps **zero** listings — expected; it's watching for the rare opening, not sweeping volume. Each detail page carries the full description inline (`job_details_content_center`…`job_details_content_bottom`), so the DE/FR + `requiresGerman` check runs in-process per candidate rather than costing `index.ts` a page fetch. Novartis Basel's titling is almost entirely non-technical "Director, `<business function>`" roles (Portfolio Analytics, Therapeutic Area Strategy, Policy Communications, ...) plus device/hardware "Engineering" roles (autoinjector/drug-delivery hardware, not software) — the generic `SOFTWARE_TITLE_RE` used by every other source in this file matched **13 of 47** CH listings on title alone in an initial run, and every single one was a false positive (no engineering-team role among them). `NOVARTIS_TITLE_RE` replaces it here: it requires an explicit software/IT engineering job-family term (`software engineer`, `platform engineer`, `data engineer`, `devops`, `sre`, `solutions architect`, ...), not just a bare seniority or business-function word. `BIO_DOMAIN_RE` and job-room.ch's `PHARMA_ROLE_RE` still run on top to catch domain-science titles the stricter regex wouldn't otherwise need to worry about. Narrowed to Basel/Muttenz specifically (`NOVARTIS_BASEL_RE`) — the CH listing also includes Stein (Aargau), a different commute the contact couldn't refer into. As of 2026-09-21 this source keeps **zero** listings — expected; it's watching for the rare opening, not sweeping volume. Each detail page carries the full description inline (`job_details_content_center`…`job_details_content_bottom`), so the DE/FR + `requiresGerman` check runs in-process per candidate rather than costing `index.ts` a page fetch.
@@ -78,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.
@@ -90,10 +92,14 @@ plans/ # LinkedIn post drafts
Swisscom mostly hires in German — of 47 postings, only ~16 clear the English/software title filters, and nearly all of those turned out to be written in German too; only genuinely-English, German-optional roles survive. Adding this source surfaced a real bug in `requiresGerman()`: the stock line *"Fluency in English is required; proficiency in German is considered an advantage"* had the noun "proficiency" (a required-marker word) sitting right next to "German", closer than the real optional-marker "advantage" — nearest() picked the wrong one and wrongly flagged the posting as requiring German. Fixed by adding a local-predicate override (`GERMAN_LOCAL_PREDICATE_RE`): when a copula ("is"/"are", optionally "considered"/"deemed") directly follows the German mention, that immediate predicate decides the verdict outright instead of competing on raw character distance with markers elsewhere in the window — "German is considered an advantage" resolves as optional regardless of what else is nearby. It only fires when a copula immediately follows the mention, so *"Work proficiency in German, English, and French"* (no copula after "German", a genuine hard requirement — Senior Operation Architect (FR/GER/ENG)) still falls through to the general nearest-marker logic and is correctly caught as required. Re-check both cases (and the pre-existing Helvetia/albedis/Karakun/J. Safra Sarasin/BBF/spec-sheet cases in the paragraph above) before touching `requiresGerman` again. Swisscom mostly hires in German — of 47 postings, only ~16 clear the English/software title filters, and nearly all of those turned out to be written in German too; only genuinely-English, German-optional roles survive. Adding this source surfaced a real bug in `requiresGerman()`: the stock line *"Fluency in English is required; proficiency in German is considered an advantage"* had the noun "proficiency" (a required-marker word) sitting right next to "German", closer than the real optional-marker "advantage" — nearest() picked the wrong one and wrongly flagged the posting as requiring German. Fixed by adding a local-predicate override (`GERMAN_LOCAL_PREDICATE_RE`): when a copula ("is"/"are", optionally "considered"/"deemed") directly follows the German mention, that immediate predicate decides the verdict outright instead of competing on raw character distance with markers elsewhere in the window — "German is considered an advantage" resolves as optional regardless of what else is nearby. It only fires when a copula immediately follows the mention, so *"Work proficiency in German, English, and French"* (no copula after "German", a genuine hard requirement — Senior Operation Architect (FR/GER/ENG)) still falls through to the general nearest-marker logic and is correctly caught as required. Re-check both cases (and the pre-existing Helvetia/albedis/Karakun/J. Safra Sarasin/BBF/spec-sheet cases in the paragraph above) before touching `requiresGerman` again.
- **more-jobs.ch** (`fetchMoreJobs`, added 2026-10-02) — server-rendered PHP aggregator. `/recherche?q=<term>&page=n` is fast (~0.5 s) with ~20 cards/page, but `/emplois` and out-of-range pages are slow (20 s+) and **clamp to the last page instead of going empty**, so a term's sweep ends on "no new ids", not an empty list. Swept with `MOREJOBS_TERMS` (≤5 pages each), then each title-filtered candidate gets one detail fetch (4 in parallel, 20 s timeout) for the real `Publication date` (dd.mm.yyyy) and the in-process DE/FR + `requiresGerman` check (`contentChecked: true`). The board **reposts one opening under several IDs** (measured: Accelleron, Swissquote, Bayer ×3), and it also mirrors jobs that LinkedIn/jobs.ch carry, so its `extraSources` entry sets `dedupeByTitle`: `index.ts` skips any job whose `langKey(title, company)` is already in the CSV (or claimed earlier in the same run). Other sources are deliberately not deduped this way. The board also lists **foreign postings** ("Eastern Europe, Poland", "95050 Santa Clara", "London") and its JSON-LD stamps `addressCountry: "CH"` on every one, so the card's location text is the only signal: `MOREJOBS_FOREIGN_RE` drops locations naming another country/foreign city or carrying a 5-digit postcode before the detail fetch (19 such rows had leaked into the CSV by 2026-10-03, several reaching the Telegram digest). - **more-jobs.ch** (`fetchMoreJobs`, added 2026-10-02) — server-rendered PHP aggregator. `/recherche?q=<term>&page=n` is fast (~0.5 s) with ~20 cards/page, but `/emplois` and out-of-range pages are slow (20 s+) and **clamp to the last page instead of going empty**, so a term's sweep ends on "no new ids", not an empty list. Swept with `MOREJOBS_TERMS` (≤5 pages each), then each title-filtered candidate gets one detail fetch (4 in parallel, 20 s timeout) for the real `Publication date` (dd.mm.yyyy) and the in-process DE/FR + `requiresGerman` check (`contentChecked: true`). The board **reposts one opening under several IDs** (measured: Accelleron, Swissquote, Bayer ×3), and it also mirrors jobs that LinkedIn/jobs.ch carry, so its `extraSources` entry sets `dedupeByTitle`: `index.ts` skips any job whose `langKey(title, company)` is already in the CSV (or claimed earlier in the same run). Other sources are deliberately not deduped this way. The board also lists **foreign postings** ("Eastern Europe, Poland", "95050 Santa Clara", "London") and its JSON-LD stamps `addressCountry: "CH"` on every one, so the card's location text is the only signal: `MOREJOBS_FOREIGN_RE` drops locations naming another country/foreign city or carrying a 5-digit postcode before the detail fetch (19 such rows had leaked into the CSV by 2026-10-03, several reaching the Telegram digest).
- **startup.jobs** (`fetchStartupJobs`, added 2026-10-06) — global startup board, swept Switzerland-wide. **Every path is behind a Cloudflare managed challenge** (403 on plain HTTP for any UA, feeds and `/api` included), and unlike basel-jobs.ch's DataDome cookie, `cf_clearance` **does not replay** over Bun's fetch — measured 403 with the exact cookie + UA; it's bound to Chrome's TLS fingerprint. What works: Chrome `--headless=new` with a throwaway profile clears the challenge **only if the UA has no "HeadlessChrome" token** (the version is read from `Chrome --version` so it doesn't drift). So this source launches a headless browser on every run — unlike basel-jobs.ch's minting, that works fine under launchd. `LINKME_NO_BROWSER=1` disables it; any failure logs `unavailable` and returns `[]`.
Listings come from **Algolia** (app `4CQMTMMK73`, index `Post_production`), not the HTML: the page embeds a *secured* search key in `meta[name="current-algolia-api-key-search"]`, and algolia.net is not behind Cloudflare, so the queries run over plain fetch. The key's `validUntil` is **~3h after page load** — read fresh every run, never cached. The key rejects every country filter (`country_code`/`country_id` → 0 hits), so the sweep is a geo bounding box around Switzerland with `country_code === "CH"` checked locally (the box also catches DE/FR/AT/LI/IT), limited to the last 60 days. Algolia caps a query at **500 retrievable hits** (100 × 5 pages) — a box over 500 is split into quadrants recursively (all ~1400 CH-box postings vs 556 in the 60-day window, measured 2026-10-06). `tagFilters` on `_tags` (Developer/Engineer) was tried and dropped: it covered only 365 of them. Records carry no description: each title-filtered candidate's detail page is fetched *inside* the browser (`page.evaluate(fetch)`, same TLS) and its JSON-LD `JobPosting.description` goes through `isViableDescription` (`contentChecked: true`). Links already in `results/jobs.csv` are skipped before that fetch. It aggregates company ATS feeds (Wise, Stripe, Avaloq, Nebius…) that LinkedIn also carries, so its `extraSources` entry sets `dedupeByTitle` like more-jobs.ch. The user's original URL was `?q=Developer&w=hybrid` around Basel; the source deliberately keeps on-site/remote too and uses `SOFTWARE_TITLE_RE` instead of the `Developer` query, matching every other source.
- **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`.
@@ -178,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:
@@ -216,6 +228,7 @@ Runs as a **launchd agent** — this machine has no cron at all, so don't reach
| plist (`~/Library/LaunchAgents/`) | when | runs | | plist (`~/Library/LaunchAgents/`) | when | runs |
|---|---|---| |---|---|---|
| `com.rafapolo.linkme-daily` | 09:00 / 12:00 / 15:00 / 17:00 / 20: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` |
| `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 linkme` to check it's loaded; `launchctl unload <plist>` to stop it. 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.
+1
View File
@@ -71,6 +71,7 @@ LinkedIn is swept by `config.ts`'s search families. Everything else lives in `fe
| **scicore.unibas.ch** | Basel | sciCORE's open-positions announcement list; used to be its own separate 08:47 watcher, now just another source | | **scicore.unibas.ch** | Basel | sciCORE's open-positions announcement list; used to be its own separate 08:47 watcher, now just another source |
| **swisscom.com** | Switzerland | Swisscom's own Workday career site, pulled via its public CXS JSON API; small board and mostly German-language, so few survive the filters | | **swisscom.com** | Switzerland | Swisscom's own Workday career site, pulled via its public CXS JSON API; small board and mostly German-language, so few survive the filters |
| **more-jobs.ch** | Switzerland | Server-rendered aggregator; keyword sweep of `/recherche?q=`, then one detail fetch per title-filtered candidate (real date + DE/FR check). Reposts of jobs other sources already carry are dropped by normalized title+company | | **more-jobs.ch** | Switzerland | Server-rendered aggregator; keyword sweep of `/recherche?q=`, then one detail fetch per title-filtered candidate (real date + DE/FR check). Reposts of jobs other sources already carry are dropped by normalized title+company |
| **startup.jobs** | Switzerland | Behind Cloudflare: a headless Chrome reads the page's short-lived Algolia search key, then the Algolia index is swept over a Swiss bounding box (split recursively past Algolia's 500-hit cap). Detail pages are fetched in-browser for the DE/FR check; reposts other sources carry are dropped by normalized title+company |
Two of these were dead until recently and are worth knowing about: Two of these were dead until recently and are worth knowing about:
+38
View File
@@ -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 |
+15
View File
@@ -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=="],
}
}
+19 -1
View File
@@ -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",
@@ -182,6 +184,21 @@ export const searches: SearchConfig[] = [
}, },
]; ];
/** 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
@@ -224,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 })
); );
+23
View File
@@ -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;
}
-234
View File
@@ -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>
-196
View File
@@ -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>
-243
View File
@@ -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
View File
@@ -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
+4 -2
View File
@@ -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] ?? "" };
}); });
} }
@@ -132,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);
+357 -11
View File
@@ -1,5 +1,7 @@
import { NON_ENGLISH_RE, JUNIOR_TITLE_RE, SOFTWARE_TITLE_RE, BIO_DOMAIN_RE, requiresGerman, hasNonEnglishUmlaut } from "./fetch.ts"; import { NON_ENGLISH_RE, JUNIOR_TITLE_RE, SOFTWARE_TITLE_RE, BIO_DOMAIN_RE, requiresGerman, hasNonEnglishUmlaut } from "./fetch.ts";
import type { Job } from "./fetch.ts"; import type { Job } from "./fetch.ts";
import { tmpdir } from "os";
import { rm } from "fs/promises";
const HEADERS = { const HEADERS = {
"User-Agent": "User-Agent":
@@ -219,6 +221,60 @@ 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, // swisscom.com — Swisscom's own Workday career site. Small board (~47 openings,
// Switzerland-wide), so no keyword sweep — pull the full listing and filter // 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 // locally, like itjobs.ch. The listing page itself is a bare JS shell (no jobs
@@ -332,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
@@ -691,7 +747,7 @@ export async function fetchSciCore(): Promise<Job[]> {
// costing a page fetch per job in index.ts. // costing a page fetch per job in index.ts.
const JOBBASEL_KEYWORDS = [ const JOBBASEL_KEYWORDS = [
"software", "engineer", "developer", "devops", "data", "python", "software", "engineer", "developer", "devops", "data", "python",
"cloud", "platform", "typescript", "backend", "full stack", "ai", "cloud", "platform", "typescript", "backend", "full stack", "ai", "agentic",
]; ];
interface JobBaselItem { interface JobBaselItem {
@@ -872,7 +928,7 @@ export async function fetchEnglishJobSearch(): Promise<Job[]> {
// empty list. Cards link to /emploi/<slug>-<id>.html; the listing carries no // empty list. Cards link to /emploi/<slug>-<id>.html; the listing carries no
// description, so each title-filtered candidate costs one detail fetch, which // description, so each title-filtered candidate costs one detail fetch, which
// also gives the real publication date (dd.mm.yyyy) and runs the DE/FR check. // also gives the real publication date (dd.mm.yyyy) and runs the DE/FR check.
const MOREJOBS_TERMS = ["software", "engineer", "developer", "devops", "platform", "architect", "data", "python"]; const MOREJOBS_TERMS = ["software", "engineer", "developer", "devops", "platform", "architect", "data", "python", "agentic"];
const MOREJOBS_MAX_PAGES = 5; const MOREJOBS_MAX_PAGES = 5;
// The board is Swiss but lists foreign postings too ("Eastern Europe, Poland", // The board is Swiss but lists foreign postings too ("Eastern Europe, Poland",
// "95050 Santa Clara", "London"), and its JSON-LD stamps addressCountry "CH" on // "95050 Santa Clara", "London"), and its JSON-LD stamps addressCountry "CH" on
@@ -1052,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;
@@ -1228,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",
@@ -1256,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;
@@ -1274,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;
@@ -1298,8 +1370,8 @@ 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,
}); });
} }
@@ -1334,7 +1406,7 @@ function parseBaselJobsCards(
// term, not just a seniority or business-function word BIO_DOMAIN_RE and // term, not just a seniority or business-function word BIO_DOMAIN_RE and
// PHARMA_ROLE_RE happen to miss. // PHARMA_ROLE_RE happen to miss.
const NOVARTIS_TITLE_RE = const NOVARTIS_TITLE_RE =
/\b(software\s+(engineer|developer|architect)|solutions?\s+architect|platform\s+engineer|cloud\s+engineer|devops(?:\s+engineer)?|data\s+engineer|site\s+reliability\s+engineer|\bsre\b|backend\s+(?:engineer|developer)|frontend\s+(?:engineer|developer)|full.?stack\s+(?:developer|engineer)|machine\s+learning\s+engineer|\bmlops\b|infrastructure\s+engineer|integration\s+engineer|automation\s+engineer|it\s+engineer|systems?\s+engineer|application[s]?\s+engineer|security\s+engineer|ai\s+engineer|engineering\s+manager|technical\s+lead|tech\s+lead|principal\s+(?:software\s+)?engineer|staff\s+engineer|product\s+engineer|programmer|it\s+architect|enterprise\s+architect|data\s+scientist|data\s+platform|digital\s+product\s+(?:engineer|manager)|it\s+project\s+manager|it\s+manager)\b/i; /\b(software\s+(engineer|developer|architect)|solutions?\s+architect|platform\s+engineer|cloud\s+engineer|devops(?:\s+engineer)?|data\s+engineer|site\s+reliability\s+engineer|\bsre\b|backend\s+(?:engineer|developer)|frontend\s+(?:engineer|developer)|full.?stack\s+(?:developer|engineer)|machine\s+learning\s+engineer|\bmlops\b|infrastructure\s+engineer|integration\s+engineer|automation\s+engineer|it\s+engineer|systems?\s+engineer|application[s]?\s+engineer|security\s+engineer|ai\s+engineer|agentic|engineering\s+manager|technical\s+lead|tech\s+lead|principal\s+(?:software\s+)?engineer|staff\s+engineer|product\s+engineer|programmer|it\s+architect|enterprise\s+architect|data\s+scientist|data\s+platform|forward\s+deployed|\bfde\b|palantir|foundry\s+(?:engineer|developer)|digital\s+product\s+(?:engineer|manager)|it\s+project\s+manager|it\s+manager)\b/i;
// Narrowed to the Basel region specifically (per the source's whole purpose — // Narrowed to the Basel region specifically (per the source's whole purpose —
// the inside contact is there), not all of Switzerland: Novartis CH postings // the inside contact is there), not all of Switzerland: Novartis CH postings
@@ -1461,11 +1533,24 @@ export async function fetchNovartisBasel(): Promise<Job[]> {
return jobs; 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++) {
@@ -1521,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.
@@ -1534,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
@@ -1552,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;
}
+1 -1
View File
@@ -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;
+7 -2
View File
@@ -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, fetchSciCore, fetchSwisscom, fetchNovartisBasel, fetchMoreJobs } 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";
@@ -54,7 +55,7 @@ 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 cells = line.replace(/^"|"$/g, "").split('","'); const cells = parseCsvLine(line);
const link = cells[5]; const link = cells[5];
if (link) existingLinks.add(link); if (link) existingLinks.add(link);
if (cells[3]) knownJobKeys.add(langKey(cells[3], cells[4] ?? "")); if (cells[3]) knownJobKeys.add(langKey(cells[3], cells[4] ?? ""));
@@ -190,6 +191,10 @@ const extraSources: Array<{ label: string; fetch: () => ReturnType<typeof fetchD
// Aggregator: reposts what LinkedIn/jobs.ch/etc. already carry, so also skip // Aggregator: reposts what LinkedIn/jobs.ch/etc. already carry, so also skip
// anything whose normalized title+company is already in the CSV. // anything whose normalized title+company is already in the CSV.
{ label: "more-jobs.ch", fetch: fetchMoreJobs, dedupeByTitle: true }, { label: "more-jobs.ch", fetch: fetchMoreJobs, dedupeByTitle: true },
// Aggregator of company ATS feeds (Wise, Stripe, Avaloq…) that LinkedIn also carries.
{ label: "startup.jobs", fetch: fetchStartupJobs, dedupeByTitle: true },
// 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, dedupeByTitle }: (typeof extraSources)[number]): Promise<void> { async function processExtraSource({ label, fetch: fetchFn, dedupeByTitle }: (typeof extraSources)[number]): Promise<void> {
+36
View File
@@ -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.
-18
View File
@@ -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
+23
View File
@@ -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
+23
View File
@@ -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
-20
View File
@@ -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
+21
View File
@@ -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
-17
View File
@@ -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
View File
@@ -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
View File
@@ -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
+177
View File
@@ -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(/&amp;/g, "&").replace(/&#39;|&#x27;/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}`);
+421
View File
@@ -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
+26
View File
@@ -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.