55 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
rodado dfba00ae91 feat: add novartis.com and more-jobs.ch as job sources
more-jobs.ch lists foreign postings tagged as CH, so drop locations naming
another country/city or a 5-digit postcode, and dedupe its reposts by
title+company.
2026-10-03 12:47:31 +02:00
rodado 3875f13713 docs: update README sources table and drop retired sciCORE watcher references 2026-09-18 11:50:39 +02:00
rodado 288b8bdcd1 feat: add sciCORE and Swisscom as job sources
sciCORE now runs inside bun fetch/daily via fetchSciCore instead of its
own standalone launchd watcher. Swisscom's Workday career site is added
as a new source (fetchSwisscom), pulled via its public CXS JSON API.

Also fixes a false positive in requiresGerman(): Swisscom's stock line
"proficiency in German is considered an advantage" was being flagged as
requiring German because the noun "proficiency" sat closer to the
mention than the real optional marker "advantage". A local-predicate
override now lets the words immediately following "German is/are ..."
decide the verdict instead of losing to raw character distance.
2026-09-18 11:49:49 +02:00
rodado 9cbc525cf5 fix: dedupe digest against all previously-sent jobs, not just this run 2026-09-14 20:12:09 +02:00
rodado 29b00cd91e fix: dedupe the digest's matched jobs across links/providers
englishjobsearch.ch listed the same posting twice under different
canton pages, once as 'Swiss AviationSoftware' and once as 'Swiss-AS' --
different clickout links and slightly different company text for the
same opening, so it showed up twice in one digest. Matched jobs are now
deduped on normalized title+company, plus a title-only pass for titles
specific enough (5+ words) to make a same-source coincidental match
implausible.
2026-09-14 12:25:06 +02:00
rodado d401dd5f9a feat: dedupe the Ignored (German/French) digest count across runs and sources
Dropped jobs never reach the CSV, so nothing remembered them -- the same
posting got re-flagged and re-counted every single bun daily run (5x/day),
and separately every time it resurfaced under a different link/provider
(e.g. Consult & Pepper's recurring Senior Fullstack Software Engineer
reposted on job-room.ch every few days and again on LinkedIn). A persisted
results/.lang-dropped-seen.json now keys on normalized title+company so
each underlying job only counts once, ever.
2026-09-14 12:12:49 +02:00
rodado 0249800278 fix: stop letting unreadable or truncated descriptions slip past the German filter
isViableContent/isViableUrl defaulted to keep a job when its description
couldn't be fetched at all -- that's what let a fully-German HRM
Systems/Consult & Pepper posting ("In Deutsch bist du verhandlungssicher")
through on 2026-09-10. They now report retry:true instead, and index.ts
leaves the job out of the CSV so a fresh fetch judges it next run rather
than defaulting to keep.

job-room.ch's search API also returns a different keyword-anchored teaser
per hit for the same ad, and only the last one was kept -- a genuinely
German posting could slip through whenever the last keyword's teaser
happened to read as English. Every teaser variant is now collected and
checked together.
2026-09-14 12:12:43 +02:00
rodadoandClaude Sonnet 5 c7b86e06f4 fix: back off longer on 429s in description fetch
fetchDescription and isEnglishContent retried rate-limited (429) requests
after only 350/700/1050ms, too short under concurrent load, so retries kept
hitting 429, exhausted, and fell back to the "can't read it, so keep"
default. That let an all-German CLEEVEN posting (job 4465075383) through
undetected and out to Telegram. Match the 1s/2s/4s backoff already used for
listing-page 429 retries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zgfzuFy3bAbs8GTh9apFc
2026-09-10 12:15:58 +02:00
rodadoandClaude Sonnet 5 821bd19ff7 feat: add Switzerland-wide remote sweep for core keywords
Core KEYWORDS only ran onsite/hybrid against Basel/Bern/Zürich; a manual
remote fullstack search turned up viable postings (PAS01, xorlab, Proton,
SCOR) that none of the existing searches covered.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zgfzuFy3bAbs8GTh9apFc
2026-09-10 12:15:46 +02:00
rodado a72a31f387 docs: adjust linkme-daily schedule to 9am/12pm/3pm/5pm/8pm 2026-09-10 10:44:03 +02:00
rodado e36bcdeb3a docs: update linkme-daily schedule to every 6h in CLAUDE.md 2026-09-10 10:42:37 +02:00
rodado d2aa61c8ad chore: archive stale CV exports and clear finished audit tasks
- Move stale CV exports (polo-cv*.html, polo-new-cv.*, rafael-polo-cv*)
  to cv/old/ — kept polo.md as the single canonical source
- Delete motivation1.md (Roche) and motivation-adobe.md — both were
  one-off letters from the CV audit, superseded
- Delete homework.md — stale scratch notes
- Mark Priority 1-3 of the CV audit as executed in tasks/to-update.md;
  only the behavioural channel-shift checklist remains open
2026-09-10 10:40:02 +02:00
rodado e066a10c3c docs: fix permit line and dates, add Rodado, drop Java from polo.md
- Swiss B permit / Portuguese (EU) citizen — "no work permit required" was
  wrong, Switzerland is not in the EU
- Correct EDGE Empower and prior-role dates
- Add Rodado (~730 Brazilian public datasets, 1TB Parquet, DuckDB,
  NL-to-SQL over MCP) alongside the existing HelvetiScan mention
- Drop Java from the skills line — the scraper itself filters Java roles
  via JAVA_TITLE_RE, so listing it as a skill was inconsistent
2026-09-10 10:39:58 +02:00
rodado 7cd1f9254d fix: lower German-teaser threshold for short job-room descriptions
job-room's search API returns a short keyword-anchored teaser (always
< 500 chars), not the full posting. The >=6 DE/FR-word threshold was
calibrated against full page text and is unreachable on a couple of
sentences, so a genuinely German teaser (Consult & Pepper's "Senior
Fullstack Software Engineer") could clear it on word count alone despite
jobs.ch itself showing "Deutsch (Fliessend)" on the full posting. Use a
lower threshold for short text; the full-page >=6 threshold stays as-is
for longer descriptions, where it was tuned to avoid false positives.
2026-09-10 10:39:54 +02:00
rodado 2e21d55807 fix: dedupe job-room.ch and jobs.ch links stored without a protocol
The link column was matched by an http(s):// pattern, but job-room.ch and
jobs.ch links are stored without a protocol (e.g. "www.jobs.ch/de/..."),
so the pattern silently skipped every one of them and re-added the same
posting on every run. Read the link by CSV column position instead.
2026-09-10 10:39:51 +02:00
rodado e3368ce161 fix: stop title filters from truncating a LinkedIn search early
fetchJobs ended pagination on `newOnPage === 0`, but newOnPage counts
jobs that survived the title filters. A page whose 25 cards all happened
to be German/Java/junior/non-software read as end-of-results and killed
the whole search, discarding every later page. The end condition is now
new raw data-entity-urn card ids, pre-filter, so what gets filtered can
no longer decide where the crawl stops.

Also add a name-only "Basel (broad)" search alongside the three geoId
ones. LinkedIn's guest search serves a different slice per query shape:
the Basel-city geoId query exhausts at 76 unique cards and never returns
some genuinely Basel-Stadt postings a name-resolved query does. PAS01's
"Full-Stack Software Engineer (60-80%)" in Basel (4461764782) was
invisible to all six Basel searches and reached the digest only because
the Bern query — also name-only — happened to return it. Union across
the Basel searches: 126 unique.
2026-09-05 18:04:36 +02:00
rodado a596281dae fix: score tech lead as its own signal so it clears minScore
A bare "Technical Lead" or "Tech Lead" only ever matched the generic
seniority bucket (2 pts, same as senior/staff), landing below the
minScore of 4 — so an explicit target role silently never reached the
digest. Give it 3 pts of its own, matching the other role-specific
signals.
2026-09-05 18:04:29 +02:00
rodado af5df25fa1 feat: show the German/French drop count in the digest
Jobs rejected for German/French content never reach jobs.csv, so daily.ts
had no way to see they existed — the digest silently under-reported how
much the run actually looked at.

index.ts now counts them across both the LinkedIn and extra-source paths
and writes results/.lang-dropped.json; daily.ts reads it back, keyed on
the digest date, and appends a count-only category. Count-only because
there is no Row to list — these were dropped before ever being parsed.
2026-09-05 18:04:29 +02:00
rodado 2b7bf3b5f7 fix: catch German descriptions by umlaut, not just DE word count
The >=6 DE_CONTENT_RE word threshold needs a reasonably long snippet to
trip. An Expleo (Bern) posting slipped through because a concurrent fetch
under load returned a truncated description with too few matches, even
though the full text (72 matches) was entirely German.

A bare umlaut or eszett is a near-certain German signal and survives
truncation — one German sentence almost always carries one. Zurich/
Zurcher is excluded as the one umlaut word that shows up constantly in
genuinely English-language Swiss postings, as a place name.

Applied in isViableContent, isEnglishContent, isViableUrl and the
fetch-other.ts sources, so every description path gets the same check.
2026-09-05 18:04:22 +02:00
rodado afddbe496d fix: systematic sweep of NON_ENGLISH_RE gaps in gender/compound notation
Audited all 2949 titles in jobs.csv against broader German-signal
heuristics than the regex itself uses, to find what it still misses:

- gender suffix generalized beyond slash/colon to also catch */_ and
  the adjectival -r form (Engineer*in, Mitarbeiter_in, Verantwortliche:r)
- 'und'/'oder' were missing entirely — basic conjunctions, zero
  English overlap
- occupational nouns (Mitarbeiter, Ingenieur, Berater, Leiter,
  Informatiker, Verantwortliche) now also match as compound suffixes
  (Aussendienstmitarbeiter, Vertriebsingenieur) instead of only
  standalone, since German compounds have no separator
- Berater, Leiter(in), Informatiker, Verantwortliche, unbefristet/
  befristet added — words that weren't on the list at all

Verified: 72 titles in jobs.csv history now correctly flag as
non-English that didn't before, all genuinely German on inspection;
re-ran the broad-heuristic audit after the fix and every remaining
candidate was a false positive from the heuristic itself (ambiguous
'Team', an English/German loanword).
2026-09-04 13:35:27 +02:00
rodado db396d0a45 fix: catch the gender-colon suffix (Entwickler:in), not just slash
"Senior Full Stack Entwickler:in 80-100 %" (ZHAW, via job-room.ch)
used the colon-gender notation instead of a slash, and Entwickler
wasn't on the German-word list either, so it slipped through.
Verified against jobs.csv history: catches 31 titles using ':in'
(Berater:in, Manager:in, Analyst:in, etc.), zero false positives.
2026-09-04 13:09:19 +02:00
rodado 274f946bd2 fix: catch German prepositions missing from NON_ENGLISH_RE title filter
"Software Engineer auf dem Weg zum Data / AI Engineer" (ti&m, via
job-room.ch) sailed through: none of the German-word list matched
'auf dem Weg zum', and the job-room description was too short to
clear the separate description-level DE/FR word-count threshold.
auf/zum/zur/vom/für/beim don't exist as English words, so they're
safe unambiguous markers — verified against the full 2858-row
jobs.csv history: only this one title now flags.
2026-09-04 11:23:08 +02:00
rodado b2e5c7865a fix: catch job-room.ch's structured German-fluency requirement
languageSkills[] (e.g. {languageIsoCode: "de", spokenLevel: "PROFICIENT"})
sits outside jobDescriptions[].description, so requiresGerman()/
isViableDescription() never saw it — the UI renders this as
"Deutsch (Fliessend)", which is exactly what kept reaching Telegram.
Measured: 69 of 1843 ads required PROFICIENT German, prose check caught
only 3.
2026-09-04 11:18:15 +02:00
rodado fccecb2254 docs: document all eight sources in the readme
The readme listed the extra sources inline in the structure block and
still described datacareer.ch as "403, currently dead". Replace that with
a Sources table covering LinkedIn plus the eight boards in fetch-other.ts,
with scope and the parsing quirk that matters for each.

Write up the two that were dead: datacareer.ch's inverted User-Agent rule,
and basel-jobs.ch's DataDome cookie — what gets through, what doesn't, how
to mint, and why a launchd run degrades instead of failing.

Also correct two things that had drifted: requiresGerman now covers
coordination, German-language phrasing and spec-sheet language lines, and
the CSV gained a location column that the Output section never mentioned.
2026-09-01 12:39:27 +02:00
rodado e083fb0747 fix: catch German requirements requiresGerman was missing
An audit of 89 basel-jobs.ch postings against their live descriptions
found 24 that mention German and 0 of them flagged. Three separate bugs,
each verified against real postings:

Coordination broke the intervening-language rule. In "Excellent
communication skills in both English and German" (Karakun) and "Fluency
in English and German" (J. Safra Sarasin), "English" sits between the
marker and the mention, so the marker was handed to English and a hard
requirement waved through. A marker now still binds when the other
language is coordinated with German (and/und/sowie/&/as well as), but
not through "and/or" or a bare "or" — "Very good English and/or German"
is satisfiable by English alone and must stay optional.

German-language markers never matched at all: `sehr\s+gute?` cannot match
"Sehr gutes Deutsch" because the trailing \b fails on the "s". Widened to
`sehr\s+gut\w*` plus fliessend\w*, Deutschkenntnisse and erforderlich.
This is why German-written postings passed the filter untouched.

Spec-sheet language lines ("Languages: German & English") have no
adjective to bind to, so the Languages:/Sprachen: label now counts as a
required marker. It still goes through the nearest-marker comparison, so
"Language: English and German (bonus)" correctly stays optional.

28 regression cases pass, covering the 15 "a plus" phrasings that must
not be dropped. On basel-jobs.ch this moves 88 viable to 85, removing
exactly the three hard requirements and keeping every optional one.

The filter is shared by every source, so rows already in jobs.csv were
written under the broken logic and are not retroactively corrected.
2026-09-01 12:24:12 +02:00
rodado d5e6605176 feat: revive datacareer.ch and integrate basel-jobs.ch
datacareer.ch had been returning 403 on every page for months. The cause
was our own User-Agent: its nginx inverts the usual bot rule and blocks
browser-like UAs while serving honest ones (no UA, curl, Googlebot).
Give it PLAIN_HEADERS instead of the shared spoofed-Chrome HEADERS.
Basel has 8 jobs on a single page with no pagination, so page 2 returning
0 items is correct rather than truncation.

basel-jobs.ch was documented as un-integrable because DataDome blocks
plain curl. Only half of that was true. Measured: plain fetch 403s on any
UA, headless Chrome over CDP 403s, Playwright's own launch 403s even
headful — but a real Chrome launched normally with --remote-debugging-port
and attached over CDP afterwards gets 200. Clearance then lives entirely
in a datadome cookie that replays fine over plain fetch with a matching
UA, so a browser is touched only to mint, never to scrape. The earlier
"cookie can't be extracted" note was true of the browser extension, not
of CDP.

Minting needs a visible window, so launchd runs can't do it; a stale
cookie makes the source log and yield nothing instead of failing the run.
Refresh by hand with `bun run mint-datadome`, or set LINKME_NO_BROWSER=1.

Three passes, all needed — they cover different sets. The informatik
category is a curated taxonomy, not the whole board, and the two search
modes query different indexes: websearch=1 finds role words and postings
filed outside IT (+147 over the category), while the bare search is the
only mode matching stack terms — react, frontend and backend return zero
under websearch=1. Language checks run against div.plain-content only;
against the whole page the German site chrome alone clears the DE/FR
threshold and drops every job, English ones included.

Also fix a pre-existing double-wrapped Promise in the extraSources type.
2026-09-01 12:23:59 +02:00
rodado b61a75eca0 docs: cover the two new sources and the concurrent fetch pipeline 2026-08-30 13:07:57 +02:00
rodado ba3039150f perf: run fetch sources concurrently instead of sequentially
Every LinkedIn search and every fetch-other.ts source hits a different
host, so there was no real reason for the old for-loop to wait on each
one in turn - that's what made a full run take 30-40+ minutes. Runs
them concurrently now (bounded: 3 at a time within each group, both
groups started together), which brought a real run down to under 90
seconds.

existingLinks.add() now 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 regression: LinkedIn started returning
occasional 429s under parallel load, and fetchJobs treated that the
same as end-of-results, silently truncating the rest of that search.
Added retry-with-backoff (3 attempts, 1s/2s/4s) so a rate limit no
longer costs real listings.
2026-08-30 13:07:32 +02:00
rodado d195d67c2a feat: add jobbasel.ch and englishjobsearch.ch job sources
jobbasel.ch's search route (/job/suche-{slug}) is server-rendered with
a window.__PRELOADED_STATE__ JSON blob, no headless browser needed.
englishjobsearch.ch works the same way via its ?format=markdown param.
Both swept with the same fixed keyword list job-room.ch already uses.

basel-jobs.ch was evaluated too but left out: it's DataDome-protected,
which blocks plain curl outright, and this project's fetchers are
intentionally browser-free.

While wiring these in, found that isEnglishDescription (now
isViableDescription) only ever ran the DE/FR word-count check against
inline descriptions, never requiresGerman() - so an English-written
posting demanding fluent German (e.g. PRODYNA's "you can communicate
fluently in both German and English") sailed straight through. That
gap existed in itjobs.ch and job-room.ch too, not just the two new
sources, so the fix applies to all four.
2026-08-30 13:07:16 +02:00
rodado 2de9809ea9 fix: group telegram digest by posting's real location, not search label
fetch.ts already parses each job card's own location, but index.ts
discarded it and stored only the search-area label in the CSV, so
daily.ts's telegram grouping used the query that found a job instead
of where it actually is (e.g. a Bern search surfacing a Lausanne job).
Now the location is written to a new CSV column and used for grouping,
with a one-time migration for existing rows.
2026-08-28 19:56:40 +02:00
rodado 7276aded92 docs: cover the digest layer and scheduled jobs
Rewrite the README around the current pipeline: the twice-daily Telegram
digest, the sciCORE watcher, Telegram setup, and the two-stage filter
pipeline.

CLAUDE.md gains the bun daily commands, the daily/profile/telegram
architecture notes, and a scheduled-automations section covering both
launchd agents. Records that fetch.ts gates what reaches the CSV while
profile.ts gates what reaches the phone.
2026-08-27 09:28:58 +02:00
rodado acfdcf97a0 feat: include posting links in sciCORE watch alerts
Keep each vacancy's href when flattening the listing HTML, resolved to an
absolute URL, so the Telegram alert links straight to the application page
instead of the listing index.

The extractor runs as python3 -c inside shell single quotes, so the anchor
pattern's quote class is built with chr(39) rather than written inline.
2026-08-27 09:28:58 +02:00
rodado 2402b5d5da docs: add motivation letters, homework and update notes 2026-08-27 09:03:05 +02:00
rodado b4957025b1 chore: update search scripts, extra sources and post drafts
Adds the one-off scripts accumulated across recent sessions, removes
temp scripts whose searches now live in config.ts as role-family
sweeps, and updates fetch-other.ts plus the LinkedIn post drafts.
2026-08-27 09:03:05 +02:00
rodado 3f5360f44c feat: send matching jobs to telegram twice daily
telegram.ts
- Bot API wrapper: HTML escaping, 4096-char chunking, rate-limit
  spacing, and --setup to discover the chat ID from the bot's inbox.
  Reads TELEGRAM_BOT (or TELEGRAM_TOKEN) and TELEGRAM_CHAT_ID.

profile.ts
- matchesProfile() scores a title against cv/polo.md. Hard rejects for
  Microsoft stack, Java/C++/SAP, aggregators (Jobgether, ALLPS),
  non-software functions and life-science domains; then weighted
  signals for the core stack, platform/DevOps, data, AI/ML, cloud and
  seniority. Threshold is 4 so short bullseye titles ("Data Engineer",
  "DevOps Engineer") pass, and forward-deployed is weighted to clear it
  on its own.

daily.ts
- Runs the fetch, diffs jobs.csv, keeps only profile matches, groups by
  area and pushes to Telegram. results/sent.txt records every link
  actually sent, written only after a successful send, so a failure
  retries instead of losing jobs and an ad is never sent twice.
- --dry-run previews, --resend --date= re-sends a past day.
- A failed fetch reports itself rather than going quiet.

Scheduled at 08:00 and 18:00 by a launchd agent
(com.rafapolo.linkme-daily). launchd runs missed jobs on wake, and the
digest is diff-based, so downtime delays delivery without losing jobs.
2026-08-27 09:03:00 +02:00
rodado 3aa4365fb9 feat: add role-family sweeps and domain/language filters
Broaden the search beyond engineer/developer titles and stop three
classes of bad result from ever reaching the CSV.

config.ts
- PLATFORM_KEYWORDS (architect, SRE, infra, security, MLOps),
  RESEARCH_KEYWORDS (research/scientific software, HPC, data platform)
  and FIELD_KEYWORDS (forward-deployed, customer/sales, founding).
  Separate OR-strings, not folded into KEYWORDS: LinkedIn ranks a
  focused query far better than one 50-term disjunction.
- sweep() generates one search per area per family. 7 searches -> 17.

fetch.ts
- BIO_DOMAIN_RE drops life-science roles (bioinformatics, biostats,
  GMP/MSAT, genomics, pathology, drug discovery). Filters on the role,
  never the employer: software jobs at Roche/Novartis/sciCORE stay.
- requiresGerman() drops English postings demanding German above B1.
  Two rules, both from real false results: the nearest marker wins
  ("Infrastructure skills are a plus" sat 95 chars before "Good
  knowledge of German"), and an intervening language name breaks the
  binding ("Fluent in English; German and/or French advantageous").
- GERMAN_TITLE_RE catches "(German-speaking)" without a page fetch.
- isViableContent/isViableUrl run the DE/FR and German-requirement
  checks on a single fetch; isEnglishContent/isEnglishUrl kept for
  recheck.ts and temp scripts.
- SOFTWARE_TITLE_RE widened so the new families survive it.

index.ts
- both content-check paths use the combined check and report which
  filter dropped a job.
2026-08-27 09:02:50 +02:00