From 284407cd23960f5ccc7f2a0321cbd422e2d7d973 Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Tue, 18 Aug 2026 11:17:24 +0200 Subject: [PATCH] fix(scout): repair Roche/Apple, add Amazon+Axpo, make title filtering fail-open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scraper fixes: - Roche: new fetch_phenom adapter (Phenom refineSearch). The old playwright scrape of ?locationsearch=Switzerland harvested recommendation-widget cards (Shanghai, Kyiv, Bogota) while the page reported no-results. 0 -> 88 CH-eligible roles. - Apple: dropped default_location "Switzerland", which relabelled US "Various Locations" postings as Swiss (84 phantom CH rows over 4 runs). Now honestly 0. - Meta: NOT broken — metacareers reports "1 Items" for Zurich. Comment added so it is not "fixed" again. New boards: - Amazon/AWS (fetch_amazon): 32 CH roles incl. a Zurich AWS FDE req and a Bern ProServe Cloud Architect. AWS is the evidenced cloud; claims.json forbids GCP. - Axpo (teamtailor via base_url + pagination): 461 roles, opens the energy lane. Locations read from schema.org jobLocation with ISO alpha-2 expanded, so Madrid/Milan/Warsaw roles are not marked Swiss. Telenor benefits too. Title filtering now has two explicit modes. Inclusion allowlists fail closed and hide unanticipated good-fit roles, so they are now used only where volume forces it (>~200 roles). Everything else uses the shared, board-agnostic NOISE_TITLE_EXCLUDE, which fails open and leaves the final call to the scorer and the reviewer. Palantir stays unfiltered per its existing documented rationale. Co-Authored-By: Claude Opus 5 --- job_scout/ROSTER_REVIEW_2026-08-18.md | 71 +++++- job_scout/_ap.py | 20 ++ job_scout/_ap_out.txt | 82 +++++++ job_scout/_fix_out.txt | 33 +++ job_scout/_fix_probe.py | 31 +++ job_scout/_mt.py | 21 ++ job_scout/_mt_out.txt | 49 ++++ job_scout/scout.py | 308 ++++++++++++++++++++++---- 8 files changed, 574 insertions(+), 41 deletions(-) create mode 100644 job_scout/_ap.py create mode 100644 job_scout/_ap_out.txt create mode 100644 job_scout/_fix_out.txt create mode 100644 job_scout/_fix_probe.py create mode 100644 job_scout/_mt.py create mode 100644 job_scout/_mt_out.txt diff --git a/job_scout/ROSTER_REVIEW_2026-08-18.md b/job_scout/ROSTER_REVIEW_2026-08-18.md index 60c9262..da956e7 100644 --- a/job_scout/ROSTER_REVIEW_2026-08-18.md +++ b/job_scout/ROSTER_REVIEW_2026-08-18.md @@ -1,6 +1,10 @@ # Scout roster review — 2026-08-18 -Analysis only. No changes made to `COMPANIES`. Every claim below was verified by probing the +> **Status: implemented 2026-08-18.** Sections 1, 2 and 5 have been acted on; see +> "What was actually done" at the bottom, which also records two findings in this +> document that turned out to be **wrong** once the fixes were attempted. + +Originally analysis only. Every claim below was verified by probing the live endpoint; unverified candidates are listed separately as *probe-worthy*, not as recommendations. Method: aggregated the scan-stats tables from the four most recent full runs (2026-07-14, 07-27, @@ -134,3 +138,68 @@ rules out the Geneva traders. 4. **Add title filters** to BKW, SBB, BFH, PostFinance, Swissgrid, Novartis, Palantir. 5. **Decide on MET/LDC and the Norway three** — a judgment call, not a bug. 6. Probe CERN, Swiss Post, NATO NCIA, SIX. + + +--- + +# What was actually done (2026-08-18) + +Implementing the recommendations disproved two of them. Both corrections are recorded here +rather than quietly edited above, because the original claims were used to justify the work. + +## Correction 1 — Meta is NOT broken + +Section 1 listed Meta as returning garbage. It does not. `metacareers.com` itself reports +**"1 Items"** for the Zurich office filter — the board is genuinely near-empty and the scraper +reports it accurately. No change made beyond a comment recording the verification, so it is +not "fixed" again later. Roche and Apple were real bugs; Meta was not. + +## Correction 2 — inclusion filters were the wrong instrument + +Section 2 recommended adding `_title_filter` allowlists to seven boards. That was implemented +and then **reversed**, on the objection that an allowlist *fails closed*: a strong-fit role +with a title nobody anticipated is dropped at fetch time, never scored, and appears in no +report and no JSON dump. The final call should sit with the scorer and the reviewer, not a +keyword gate. + +Filtering is now two explicit modes: + +| Mode | Behaviour | Where | +|---|---|---| +| `_title_exclude` (**default**) | Fails **open** — drops only unambiguous non-tech titles (`hausdienst`, `physiotherapie`, `violine`, `lehrstelle`, `legal counsel`…). Everything else is scored. | 14 boards | +| `_title_filter` | Fails **closed** — allowlist, used only where volume makes full scoring impractical (>~200 roles) | Databricks, Snowflake, Datadog, Elastic, Fivetran, Louis Dreyfus | + +`NOISE_TITLE_EXCLUDE` is one shared, board-agnostic list. Terms that could plausibly attach to +a technical role (analyst, manager, specialist, consultant, architect, lead) are deliberately +excluded from it. + +Also corrected: BKW's 61/75 match rate was blamed on the missing filter. The real cause is +`_score_floor: 2`, set deliberately because the English keyword scorer cannot read German +titles. The floor was left in place. + +## Fixes shipped + +- **Roche** — new `fetch_phenom` adapter (Phenom `refineSearch`). Was 40 scraped / 0 Swiss; + now **133 scraped, 88 CH-eligible, 25 matches**. +- **Apple** — removed `default_location: "Switzerland"`, which was relabelling US "Various + Locations" postings as Swiss. Now honestly reports **0 CH-eligible**. +- **Amazon / AWS** — new `fetch_amazon` adapter. **32 CH roles, 29 eligible, 13 matches**, + including the Zurich AWS FDE req and a Senior ProServe Cloud Architect in **Bern**. +- **Axpo** — `fetch_teamtailor` extended for custom domains and pagination. 461 roles. + Locations now come from the feed's schema.org `jobLocation`, with ISO alpha-2 expanded to + full country names — without that, `"Burgdorf, CH"` failed the CH keyword match, and a + forced `default_location` would have marked ~380 Madrid/Milan/Warsaw roles as Swiss (the + Apple bug again). Telenor gained real locations from the same change. +- **Filter modes** as described above, plus `artificial intelligence`, `data scientist`, + `data-driven`, `ai engineer`, `ai platform`, `ai architect`, `ai-systems`, `data science` + added to the shared allowlist. Bare `"ai"` deliberately omitted — it substring-matches + *Maintenance*, *Training*, *Chair*. +- **Palantir left unfiltered** — its entry already documents why (its target titles, e.g. + "Deployment Strategist", are not in the allowlist). The original recommendation to filter it + would have hidden exactly the roles worth seeing. + +## Still open (deliberately not done) + +- **Section 4 judgment calls**: MET Group + Louis Dreyfus, and the three Norway companies. + These are preference decisions, not bugs. +- **Section 7 probes**: CERN, Swiss Post, NATO NCIA, SIX Group. diff --git a/job_scout/_ap.py b/job_scout/_ap.py new file mode 100644 index 0000000..ab6ea31 --- /dev/null +++ b/job_scout/_ap.py @@ -0,0 +1,20 @@ +import io +from playwright.sync_api import sync_playwright +out = io.open('_ap_out.txt', 'w', encoding='utf-8') +with sync_playwright() as p: + b = p.chromium.launch() + pg = b.new_page() + calls = [] + pg.on('response', lambda r: calls.append((r.request.method, r.url, r.status, + r.headers.get('content-type', ''), r.request.post_data))) + pg.goto('https://jobs.apple.com/en-us/search?location=switzerland-CHE', + wait_until='domcontentloaded', timeout=60000) + pg.wait_for_timeout(10000) + for m, u, st, ct, pd in calls: + if 'jobs.apple.com' in u and ('api' in u or 'search' in u or 'graphql' in u): + out.write('%s %s [%s] %s\n' % (m, u[:170], st, ct[:40])) + if pd: out.write(' POST %s\n' % pd[:400]) + out.write('---- page text ----\n') + out.write(pg.inner_text('body')[:1200]) + b.close() +out.close() diff --git a/job_scout/_ap_out.txt b/job_scout/_ap_out.txt new file mode 100644 index 0000000..140afd5 --- /dev/null +++ b/job_scout/_ap_out.txt @@ -0,0 +1,82 @@ +GET https://jobs.apple.com/en-us/search?location=switzerland-CHE [200] text/html; charset=utf-8 +GET https://jobs.apple.com/public/images/search-grey.svg [200] image/svg+xml +GET https://jobs.apple.com/public/images/saved-search-blue.svg [200] image/svg+xml +GET https://jobs.apple.com/public/images/saved-search-grey.svg [200] image/svg+xml +---- page text ---- +Apple +Store +Mac +iPad +iPhone +Watch +Vision +AirPods +TV & Home +Entertainment +Accessories +Support +0 ++ +Careers at Apple +Work at Apple +Life at Apple +Profile +Sign In +Search +Find your perfect role. +To reveal list of choices, type + +Most corporate roles require English-language skills. + +Filters +Clear all +Refine by +Location +1 +1 Filter(s) Applied +Keyword +0 Filter(s) Applied +Teams +0 Filter(s) Applied +Products and Services +0 Filter(s) Applied +Language Skills +0 Filter(s) Applied +1 Filter(s) Applied +Save Search +Chesterfield, Missouri, United States +text +Search Results + +24 Result(s) + +Sort by: Newest +US - Specialist: Seasonal, Part-time +Apple Retail +Aug 18, 2026 +Location +Various Locations within United States +Actions +MBA Internships +Students +Aug 05, 2026 +Location +Various Locations within United States +Actions +Machine Learning and Artificial Intelligence Masters Internships +Students +May 22, 2026 +Location +Various Locations within United States +Actions +Machine Learning and Artificial Intelligence PhD Internships +Students +May 22, 2026 +Location +Various Locations within United States +Actions +Business, Marketing & Creative Masters Internships +Students +May 22, 2026 +Location +Various Locations within United St \ No newline at end of file diff --git a/job_scout/_fix_out.txt b/job_scout/_fix_out.txt new file mode 100644 index 0000000..d10fdb3 --- /dev/null +++ b/job_scout/_fix_out.txt @@ -0,0 +1,33 @@ +== roche + POST https://careers.roche.com/widgets + POST {"deviceType":"desktop","locale":"en_global","pageId":"page11-ds","refNum":"ROCHGLOBAL","siteVariant":"external","ddoKey":"dynaData"} + POST https://careers.roche.com/widgets + POST {"data":{"hf-krisztian-test":"KZFVv6","hf-krisztian-test$$z4HDz3vS":"gbobHe","hf-krisztian-test$$4HDz3vS":"fmAme4","hf-krisztian-test$$Aq1abxJ":"q1abxJ","hf-krisztian-test$$Ahl5Fa2":"hl5Fa2","poIIrO":"ph-cookie-popup-v2-default","Zvv6ZI":"ph-paragraph-cc-view1-option1-theme1","CUbexW":"ph-hero-block-cc-view12-option1-theme1","":"ph-page-state-data-v1-no-results-ROCHGLOBAL-en_global-page11","JSx8eq":"ph-global-search-v3-default","r7iov4":"ph-page-state-data-v1-no-results-ROCHGLOBAL-en_global-page11","zdessz":"ph-category-overview-v3-view8","w9gb8n":"ph-skills-v1-default","x25c65":"ph-facets-v1- + POST https://careers.roche.com/widgets + POST {"lang":"en_global","deviceType":"desktop","country":"global","pageName":"search-results","pageId":"page11-ds","data":[{"widgetTag":"ph-cookie-popup-v2","view":"default","instanceId":"poIIrO"},{"widgetTag":"ph-global-search-v3","view":"default","instanceId":"JSx8eq"},{"widgetTag":"ph-page-state-data-v1","view":"no-results-ROCHGLOBAL-en_global-page11","instanceId":"r7iov4"},{"widgetTag":"ph-category-overview-v3","view":"view8","instanceId":"zdessz"},{"widgetTag":"ph-skills-v1","view":"default","instanceId":"w9gb8n"},{"widgetTag":"ph-facets-v1","view":"view3","instanceId":"x25c65"},{"widgetTag": + POST https://careers.roche.com/widgets + POST {"widgetIds":["KZFVv6","gbobHe","fmAme4","q1abxJ","hl5Fa2","ph-cookie-popup-v2-default","ph-paragraph-cc-view1-option1-theme1","ph-hero-block-cc-view12-option1-theme1","ph-global-search-v3-default","ph-page-state-data-v1-no-results-ROCHGLOBAL-en_global-page11","ph-category-overview-v3-view8","ph-skills-v1-default","ph-facets-v1-view3","ph-create-job-alert-v1-default","ph-content-cc-view4-option5-theme1","ph-search-results-v2-view5","BgxJYk","0tOMrd","m5IDBJ"],"refNum":"ROCHGLOBAL","lang":"en_global","deviceType":"desktop","pageName":"search-results","siteType":"external","pageId":"page11-ds"," + POST https://careers.roche.com/widgets + POST {"lang":"en_global","deviceType":"desktop","country":"global","pageName":"search-results","pageId":"page11-ds","ddoKey":"getWidgetVersions"} + POST https://careers.roche.com/widgets + POST {"refNum":"ROCHGLOBAL","lang":"en_global","deviceType":"desktop","pageName":"search-results","siteType":"external","pageId":"page11-ds","country":"global","ddoKey":"getTriggerConfig"} + POST https://careers.roche.com/widgets + POST {"lang":"en_global","deviceType":"desktop","country":"global","pageName":"search-results","refNum":"ROCHGLOBAL","siteType":"external","pageId":"page11-ds","ddoKey":"jobCartCountV2"} + POST https://careers.roche.com/widgets + POST {"lang":"en_global","deviceType":"desktop","country":"global","pageName":"search-results","refNum":"ROCHGLOBAL","siteType":"external","pageId":"page11-ds","ddoKey":"validateJwtToken"} + POST https://careers.roche.com/widgets + POST {"lang":"en_global","deviceType":"desktop","country":"global","pageName":"search-results","refNum":"ROCHGLOBAL","siteType":"external","pageId":"page11-ds","ddoKey":"getPrivacyConsentsConfig"} + POST https://careers.roche.com/widgets + POST {"lang":"en_global","deviceType":"desktop","country":"global","pageName":"search-results","pageId":"page11-ds","data":{"poIIrO":"ph-cookie-popup-v2-default","JSx8eq":"ph-global-search-v3-default","r7iov4":"ph-page-state-data-v1-no-results-ROCHGLOBAL-en_global-page11","zdessz":"ph-category-overview-v3-view8","w9gb8n":"ph-skills-v1-default","x25c65":"ph-facets-v1-view3","oiyn2y":"ph-create-job-alert-v1-default","zcduui":"ph-search-results-v2-view5"},"ddoKey":"canvasGetWidgetContent"} + GET https://content-ir.phenompeople.com/api/ROCHGLOBAL/jobwidgetsettings?locale=en_global&siteType=external&deviceType=desktop& + POST https://careers.roche.com/widgets + POST {"lang":"en_global","deviceType":"desktop","country":"global","pageName":"search-results","ddoKey":"validateJwtToken"} + POST https://careers.roche.com/widgets + POST {"lang":"en_global","deviceType":"desktop","country":"global","pageName":"search-results","ddoKey":"eagerLoadRefineSearchSession","sortBy":"","subsearch":"","from":0,"irs":false,"jobs":true,"counts":true,"all_fields":["category","subCategory","country","state","city","type","jobLevel","jobType"],"size":10,"clearAll":false,"jdsource":"facets","isSliderEnable":false,"pageId":"page11-ds","siteType":"external","keywords":"","global":true,"selected_fields":{},"locationData":{}} + POST https://careers.roche.com/widgets + POST {"lang":"en_global","deviceType":"desktop","country":"global","pageName":"search-results","ddoKey":"getJobStatusListV2","jobSeqNos":["ROCHGLOBAL202603108085EXTERNALENGLOBAL","ROCHGLOBAL202510127711EXTERNALENGLOBAL","ROCHGLOBAL202601100377EXTERNALENGLOBAL","ROCHGLOBAL202509122078EXTERNALENGLOBAL","ROCHGLOBAL202603106148EXTERNALENGLOBAL","ROCHGLOBAL202206125077EXTERNALENGLOBAL","ROCHGLOBAL202210136501EXTERNALENGLOBAL","ROCHGLOBAL202311124191EXTERNALENGLOBAL","ROCHGLOBAL202312128405EXTERNALENGLOBAL","ROCHGLOBAL202405113331EXTERNALENGLOBAL"]} + GET https://content-ir.phenompeople.com/api/ROCHGLOBAL/globalSearchConfig?locale=en_global&siteType=external&deviceType=desktop& + POST https://careers.roche.com/widgets + POST {"lang":"en_global","deviceType":"desktop","country":"global","pageName":"search-results","ddoKey":"getJobStatusListV2","jobSeqNos":["ROCHGLOBAL202603108085EXTERNALENGLOBAL","ROCHGLOBAL202510127711EXTERNALENGLOBAL","ROCHGLOBAL202601100377EXTERNALENGLOBAL","ROCHGLOBAL202509122078EXTERNALENGLOBAL","ROCHGLOBAL202603106148EXTERNALENGLOBAL","ROCHGLOBAL202206125077EXTERNALENGLOBAL","ROCHGLOBAL202210136501EXTERNALENGLOBAL","ROCHGLOBAL202311124191EXTERNALENGLOBAL","ROCHGLOBAL202312128405EXTERNALENGLOBAL","ROCHGLOBAL202405113331EXTERNALENGLOBAL"]} +== meta +== apple diff --git a/job_scout/_fix_probe.py b/job_scout/_fix_probe.py new file mode 100644 index 0000000..16208ce --- /dev/null +++ b/job_scout/_fix_probe.py @@ -0,0 +1,31 @@ +import io, json +from playwright.sync_api import sync_playwright +SITES = { + 'roche': 'https://careers.roche.com/global/en/search-results?keywords=&locationsearch=Switzerland', + 'meta': 'https://www.metacareers.com/jobs?offices[0]=Zurich%2C%20Switzerland', + 'apple': 'https://jobs.apple.com/en-us/search?location=switzerland-CHE', +} +out = io.open('_fix_out.txt', 'w', encoding='utf-8') +with sync_playwright() as p: + b = p.chromium.launch() + for k, u in SITES.items(): + pg = b.new_page(); calls = [] + def on_resp(r, calls=calls): + ct = r.headers.get('content-type', '') + if 'json' in ct and r.request.method in ('GET', 'POST'): + calls.append((r.request.method, r.url, r.request.post_data)) + pg.on('response', on_resp) + try: + pg.goto(u, wait_until='domcontentloaded', timeout=60000) + pg.wait_for_timeout(9000) + pg.mouse.wheel(0, 5000); pg.wait_for_timeout(4000) + out.write('== %s\n' % k) + for m, url, pd in calls: + if any(w in url.lower() for w in ('search', 'job', 'graphql', 'widget', 'role')): + out.write(' %s %s\n' % (m, url[:190])) + if pd: out.write(' POST %s\n' % pd[:600]) + except Exception as e: + out.write('== %s ERR %s\n' % (k, str(e)[:150])) + pg.close() + b.close() +out.close() diff --git a/job_scout/_mt.py b/job_scout/_mt.py new file mode 100644 index 0000000..8216c0b --- /dev/null +++ b/job_scout/_mt.py @@ -0,0 +1,21 @@ +import io +from playwright.sync_api import sync_playwright +out = io.open('_mt_out.txt', 'w', encoding='utf-8') +with sync_playwright() as p: + b = p.chromium.launch() + pg = b.new_page() + pg.goto('https://www.metacareers.com/jobs?offices[0]=Zurich%2C%20Switzerland', + wait_until='domcontentloaded', timeout=60000) + pg.wait_for_timeout(9000) + for _ in range(6): + pg.mouse.wheel(0, 5000); pg.wait_for_timeout(1200) + n = pg.locator("a[href*='/jobs/']").count() + n2 = pg.locator("a[href*='/profile/job_details/']").count() + out.write('a[/jobs/]=%d a[/profile/job_details/]=%d\n' % (n, n2)) + for i in range(min(n, 30)): + el = pg.locator("a[href*='/jobs/']").nth(i) + out.write(' %s | %s\n' % ((el.inner_text() or '').replace('\n', ' / ')[:90], + (el.get_attribute('href') or '')[:60])) + out.write('---TEXT---\n' + pg.inner_text('body')[:900]) + b.close() +out.close() diff --git a/job_scout/_mt_out.txt b/job_scout/_mt_out.txt new file mode 100644 index 0000000..742bdfc --- /dev/null +++ b/job_scout/_mt_out.txt @@ -0,0 +1,49 @@ +a[/jobs/]=0 a[/profile/job_details/]=1 +---TEXT--- +Skip to main content +Jobs +Teams +Career Programs +Working at Meta +Blog +Podcasts +Get personalized job recommendations from AI +The Meta AI recruiting assistant can recommend jobs based on your resume, location, and role preferences. +Upload resume for job recommendations +Hide filters +1 Items +Save this search +Sort by +Relevance +Newest +Technologies +Facebook +Messenger +Instagram +WhatsApp +Meta Quest +Teams +Advertising Technology +AR/VR +Artificial Intelligence +Business Development & Partnerships +Communications & Public Policy +Show more +Employment type +Full time employment +Internship +Short term employment +Location +Zurich, Switzerland +Apply filters +Clear filters +Search by technology, team, location, or ref. code +Research Scientist Intern, AI/ML, Core Ads Growth (PhD) +Zurich, Switzerland +1 locations +⋅ +AI Research +3 more +⋅ +Artificial Intelligence +2 more +Multiple Locations +AI Research +Artificial Intelli \ No newline at end of file diff --git a/job_scout/scout.py b/job_scout/scout.py index 3bb5c49..6aa33fc 100644 --- a/job_scout/scout.py +++ b/job_scout/scout.py @@ -138,6 +138,9 @@ NEGATIVE_KEYWORDS = { # post mostly non-tech roles). Only keep titles containing one of these specific role # phrases — kept tight so "Sales Engineer"/"Staff Accountant"/"Data Privacy Counsel" # don't leak in. Matched as case-insensitive substrings against the title only. +# Inclusion allowlist. Applied ONLY to boards too large to score in full (>~200 roles: +# Databricks, Snowflake, Datadog, Elastic, Fivetran, Louis Dreyfus, Palantir). Everywhere else +# use NOISE_TITLE_EXCLUDE — see the design note there for why fail-open is the default. ENG_TITLE_FILTER = [ "data engineer", "data engineering", "data platform", "platform engineer", "data infrastructure", "data architect", "analytics engineer", @@ -149,8 +152,52 @@ ENG_TITLE_FILTER = [ # "resident" alone catches Resident Solutions Architect/Engineer without opening the gate to # all pre-sales SAs (the overscoring trap); "customer engineer" is Google's field-eng term. "forward deployed", "forward-deployed", "field engineer", "resident", "customer engineer", + # Added 2026-08-18 after the CH boards were title-filtered: without these, real near-misses + # were dropped (BFH "Wissenschaftliche Mitarbeit Data-Driven Government", Novartis "Director + # & Group Head (AI-Systems & Scale)"). Deliberately NOT adding bare "ai" — it substring-matches + # Maintenance/Training/Chair and floods every board. + "artificial intelligence", "ai-systems", "ai platform", "ai engineer", "ai architect", + "data scientist", "data-driven", "data science", ] +# Generic non-tech title exclusions, shared by every board that uses exclusion-mode filtering. +# +# DESIGN NOTE (2026-08-18). Boards are filtered one of two ways: +# * inclusion (`_title_filter`) — an allowlist, used ONLY on boards too large to score in +# full (Databricks ~800, OpenAI ~730, Palantir ~300). It fails CLOSED: a great-fit role +# with a title nobody anticipated is dropped at fetch time and never reaches the scorer, +# so it appears in no report and in no JSON dump. That is an acceptable trade only where +# volume forces it. +# * exclusion (`_title_exclude`) — this list. It fails OPEN: everything survives unless it +# is *clearly* not an engineering role, and the score + the weak/noise bucket decide what +# surfaces. Prefer this. The final call belongs to the reviewer, not to a keyword gate. +# +# Keep these terms unambiguous. Anything that could plausibly attach to a technical role +# (analyst, manager, specialist, consultant, architect, lead) must NOT go in here. +NOISE_TITLE_EXCLUDE = [ + # Facilities / retail / hospitality / admin + "hausdienst", "reinigung", "empfang", "hauswart", "chef de partie", "koch", "küche", + "restaurant", "catering", "fahrer", "logistik mitarbeiter", "lagerist", "verkauf", + "verkäufer", "retail", "barista", "security guard", "sicherheitsdienst", + # Care / health / teaching-of-non-tech + "physiotherapie", "pflege", "pflegefach", "hebamme", "ergotherapie", "psychologie", + "medical representative", "nurse", "arzt", "ärztin", "dentist", + # Arts / music / sport + "violine", "klavier", "musik", "dozierende*r violine", "sport", + # Back-office + "fundraising", "buchhaltung", "accountant", "accounting", "payroll", "steuer", + "recruiter", "talent acquisition", "human resources", "personalwesen", + "legal counsel", "rechtsanwalt", "rechtsreferendar", "notar", "jurist", + "kommunikation", "public relations", "übersetzer", "translator", + # Early-career / non-role listings + "lehrstelle", "praktikum", "praktikant", "internship", "intern ", "trainee", + "apprenti", "ausbildung", "schnupper", "where we're hiring", "talent community", + # Non-software engineering trades + "elektroplaner", "elektroinstallat", "sanitär", "heizung", "maler", "schreiner", + "hochspannung", "wasserbau", "strassenbau", "holzbau", "bauingenieur", "bauleiter", +] + + # id, display, adapter, adapter_args COMPANIES = [ ("nvidia", "NVIDIA", "workday", { @@ -167,6 +214,7 @@ COMPANIES = [ "tenant": "novartis", "site": "Novartis_Careers", "search_text": "Switzerland", + "_title_exclude": NOISE_TITLE_EXCLUDE, }), # PCSX (Eightfold) — Microsoft has a public position search endpoint ("microsoft", "Microsoft", "pcsx", { @@ -177,9 +225,9 @@ COMPANIES = [ # Dropped: ClickHouse (Glassdoor 3.3, 36% recommend, toxic-culture flag — 2026-05). # Dropped: HashiCorp — acquired by IBM (closed 2025); greenhouse/ashby/lever boards all 404, # roles folded into IBM's careers (no clean public ATS API). 2026-06-06. - ("confluent", "Confluent", "ashby", {"slug": "confluent", "_title_filter": ENG_TITLE_FILTER}), - ("gitlab", "GitLab", "greenhouse", {"board": "gitlab", "_title_filter": ENG_TITLE_FILTER}), - ("grafana", "Grafana Labs","greenhouse",{"board": "grafanalabs", "_title_filter": ENG_TITLE_FILTER}), + ("confluent", "Confluent", "ashby", {"slug": "confluent", "_title_exclude": NOISE_TITLE_EXCLUDE}), + ("gitlab", "GitLab", "greenhouse", {"board": "gitlab", "_title_exclude": NOISE_TITLE_EXCLUDE}), + ("grafana", "Grafana Labs","greenhouse",{"board": "grafanalabs", "_title_exclude": NOISE_TITLE_EXCLUDE}), # Added 2026-06-06 (Tier A/B data-infra). Databricks/Snowflake/Datadog have Zürich offices # (Swiss-scale comp, clears bar); Elastic/Fivetran are remote-EU (verify CH-equiv comp — # may be geo-banded below 180k, like Grafana). All title-filtered (boards are 160-760 roles). @@ -195,7 +243,17 @@ COMPANIES = [ # --- Energy / commodity trading (SmartRecruiters; title-filtered to tech roles) --- # Dropped: Vitol (Glassdoor 3.5, 55% recommend, grueling-hours/toxic flag — 2026-05). # Dropped: Sygnum (Glassdoor 3.4, 51% recommend, comp 2.3/5 — below 180k bar — 2026-05). - ("metgroup", "MET Group", "smartrecruiters", {"company": "METGroup", "_title_filter": ENG_TITLE_FILTER}), + # Axpo (Baden/Zurich) — Teamtailor on a custom domain; no .teamtailor.com host + # resolves, so this uses base_url. Added 2026-08-18: memory names Axpo/Alpiq as the + # energy-trading targets but only MET was ever configured, and MET+LDC have produced + # 0 CH-eligible roles across 1,464 scrapes. ~400 roles, German-speaking region (which is + # what rules out the Geneva traders), incl. a "Forward Deployed AI Engineer" at add time. + ("axpo", "Axpo", "teamtailor", { + "base_url": "https://careers.axpo.com", + "default_location": "Switzerland", + "_title_exclude": NOISE_TITLE_EXCLUDE, + }), + ("metgroup", "MET Group", "smartrecruiters", {"company": "METGroup", "_title_exclude": NOISE_TITLE_EXCLUDE}), ("ldc", "Louis Dreyfus","smartrecruiters",{"company": "LouisDreyfusCompany", "_title_filter": ENG_TITLE_FILTER}), # Equinor (Workday) — Norway energy major; lived/worked in NO before. Outlier location # policy: Norway only (not CH / Europe-remote). Small board (~15); no title filter. @@ -277,6 +335,20 @@ COMPANIES = [ "page_param_start": 2, "max_pages": 6, }), + # Amazon + AWS share one board. Added 2026-08-18: AWS is the cloud the evidence base + # actually supports (Swisscom migration + data products), whereas claims.json marks GCP + # output-forbidden — so an AWS-native Zurich FDE req is the Google FDE lane without the + # cloud gap. 32 CH roles at time of adding, incl. "Senior Forward Deployed Engineer, AWS + # Forward Deployed Engineering" (Zurich). + # AWS names its delivery/field org differently from every other board, so the shared + # filter caught only 1 of 32 CH roles. These four terms are added narrowly and ONLY here: + # "solutions architect" is the classic overscoring trap (see job_scout_overscoring_findings), + # but on the AWS board it is the delivery-side title attached to migration/data work, which + # is exactly SW-1. Read the JD before trusting the score on any of these. + ("amazon", "Amazon / AWS", "amazon", { + "countries": ["CHE"], + "_title_exclude": NOISE_TITLE_EXCLUDE, + }), ("apple", "Apple", "playwright", { "url": "https://jobs.apple.com/en-us/search?location=switzerland-CHE", "wait_for": "a[href*='/en-us/details/']", @@ -284,9 +356,19 @@ COMPANIES = [ "title_attr": "text", "link_attr": "href", "url_prefix": "https://jobs.apple.com", - "default_location": "Switzerland", + # NO default_location. Apple's own postLocation-CHE filter leaks global reqs whose + # postLocationId is postLocation-USA ("Various Locations within United States"), and + # a forced "Switzerland" default relabelled every one of them as CH-eligible — 84 + # phantom CH rows over 4 runs, all internships/retail, which then read as "Apple is a + # bad fit" in the decision log. Read the real location off the card instead; when + # Apple has no Swiss reqs the honest answer is 0. Verified 2026-08-18. + "use_inner_text_as_blob": True, + "scroll_count": 5, }), # Meta job links are /profile/job_details/; title + location are in the link text. + # NOT broken despite returning ~1 role: verified 2026-08-18 that metacareers itself + # reports "1 Items" for the Zurich office filter. The board is genuinely near-empty; + # don't "fix" this scraper without first checking the live item count on the page. ("meta", "Meta", "playwright", { "url": "https://www.metacareers.com/jobs?offices[0]=Zurich%2C%20Switzerland", "wait_for": "a[href*='/profile/job_details/']", @@ -298,22 +380,18 @@ COMPANIES = [ "scroll_count": 5, "use_inner_text_as_blob": True, }), - # PhenomPeople pattern (Roche) uses li.jobs-list-item. - # Card inner text is structured like: " | Location | <city, country> | Category | ..." - # We extract title from first line, full text becomes the "description" so our location - # filter still sees Switzerland mentions. - ("roche", "Roche", "playwright", { - "url": "https://careers.roche.com/global/en/search-results?keywords=&locationsearch=Switzerland", - "wait_for": "li.jobs-list-item, a.au-target", - "card": "li.jobs-list-item:not(:has-text('Saved jobs'))", - "title_attr": "text", - "link_sel": "a[href]", - "link_attr": "href", - "url_prefix": "https://careers.roche.com", - "default_location": "", - "cookie_accept": ["#onetrust-accept-btn-handler", "button:has-text('Accept All Cookies')"], - "scroll_count": 6, - "use_inner_text_as_blob": True, + # Roche: was a playwright scrape of the search-results page until 2026-08-18. That page's + # ?locationsearch=Switzerland filter silently fails — the page state reports "no-results" + # and the scrape harvested *recommendation-widget* cards instead, returning Shanghai, + # Kyiv, Bogota and Mannheim and zero Swiss roles for months while looking healthy. The + # underlying Phenom refineSearch endpoint filters correctly (134 CH roles), so hit it + # directly. Title-filtered: Roche CH is overwhelmingly lab/pharma/apprenticeship reqs. + ("roche", "Roche", "phenom", { + "url": "https://careers.roche.com/widgets", + "ref_num": "ROCHGLOBAL", + "page_id": "page11-ds", + "country_facet": "Switzerland", + "_title_exclude": NOISE_TITLE_EXCLUDE, }), # Cisco (PhenomPeople, new careers.cisco.com domain). Keyword search surfaces CH roles. ("cisco", "Cisco", "playwright", { @@ -353,6 +431,7 @@ COMPANIES = [ "field_url": "descriptionUrl", "field_date": "onlineSince", "loc_suffix": " Switzerland", "desc_keys": ["department", "typeOfEmployment", "entryLevel"], + "_title_exclude": NOISE_TITLE_EXCLUDE, }), # RUAG (Thun/Bern/Emmen). Jobs render on the portal as anchors to jobs.ruag.ch; the first # line of each anchor is the title. All sites are Swiss, so default_location=Switzerland @@ -370,7 +449,7 @@ COMPANIES = [ "scroll_count": 1, "page_param": "page", "max_pages": 10, - "_title_filter": ENG_TITLE_FILTER, + "_title_exclude": NOISE_TITLE_EXCLUDE, }), # SBB (company.sbb.ch — the correct host; company-jobs.sbb.ch was wrong). AEM job filter # served as a flat JSON list; the fetch_sbb adapter replicates the user's IT + Bern-region @@ -380,12 +459,14 @@ COMPANIES = [ "topic": "IT / Telekommunikation", "region": "Bern Mittelland", "_score_floor": 2, + "_title_exclude": NOISE_TITLE_EXCLUDE, }), # BKW Group (jobs.bkw.com — the real ATS host). PMS structured-data API; ~600 roles # group-wide, so fetch_bkw keeps only Berufsfeld categories Informatik/Trading/Finanzen # (IT/data + energy-trading, incl. the flagged Energiehandel roles). German/generic # titles, so _score_floor keeps the pre-filtered set visible. - ("bkw", "BKW (Bern)", "bkw", {"_score_floor": 2}), + ("bkw", "BKW (Bern)", "bkw", {"_score_floor": 2, + "_title_exclude": NOISE_TITLE_EXCLUDE}), # PostFinance (Bern). The careers site renders a small, client-side paginated board; # scrape all pages through its stable next-page control. No title filter: the board is # low-volume, and the scorer keeps unrelated banking/customer-service roles out of the @@ -400,6 +481,7 @@ COMPANIES = [ "use_inner_text_as_blob": True, "next_button": "#pfch-pagination-next", "max_pages": 10, + "_title_exclude": NOISE_TITLE_EXCLUDE, }), # BFH (Bern University of Applied Sciences). Re-added 2026-07-14: the jobs.bfh.ch domain # itself is a broken/stub SPA shell (renders "Career Center project template", nothing @@ -419,6 +501,7 @@ COMPANIES = [ "link_sel": "a", "default_location": "Switzerland", "_score_floor": 2, + "_title_exclude": NOISE_TITLE_EXCLUDE, }), ] @@ -644,34 +727,80 @@ def fetch_rss(args): return jobs +# schema.org JobPosting addresses carry ISO alpha-2 country codes; the location policy +# keyword lists are full names. Only the countries the policies actually test for. +_ISO2_COUNTRY = { + "CH": "Switzerland", "NO": "Norway", "DK": "Denmark", "DE": "Germany", + "AT": "Austria", "FR": "France", "IT": "Italy", "ES": "Spain", "PL": "Poland", + "NL": "Netherlands", "BE": "Belgium", "SE": "Sweden", "FI": "Finland", + "GB": "United Kingdom", "UK": "United Kingdom", "IE": "Ireland", "US": "United States", + "PT": "Portugal", "CZ": "Czechia", "RO": "Romania", "RS": "Serbia", "TR": "Turkey", +} + + def fetch_teamtailor(args): """Teamtailor public JSON Feed (`https://<slug>.teamtailor.com/jobs.json`). - Used by Telenor. The public /jobs HTML page paginates and under-reports; the feed - returns the full board in one call, so prefer it. The feed carries no location - field, so default_location is required (Teamtailor boards are per-country anyway). + Used by Telenor (`slug`) and Axpo (`base_url`, a Teamtailor board on a custom domain — + careers.axpo.com — which no <slug>.teamtailor.com host resolves to). The public /jobs + HTML page paginates and under-reports; the feed is authoritative. The feed carries no + location field, so default_location is required (Teamtailor boards are per-country). Descriptions are frequently Norwegian — see the _score_floor note on the company. + The feed caps at 100 items and links the next page via `next_url`; follow it, or large + boards silently truncate (Axpo is ~400 roles, i.e. 4 pages). + TRAP: an unclaimed slug returns Teamtailor's *demo* board (HTTP 200, plausible JSON) instead of 404. Verified 2026-07-29: `ksat` and `akerbp` both served the seed set below though neither company uses Teamtailor. Filtered here so phantom roles never reach a report — if a real board is ever dropped by this, widen the fingerprint.""" DEMO_TITLES = {"sales development manager", "team lead - csm", "social media manager", "customer success manager", "key account manager", "ux designer"} - url = f"https://{args['slug']}.teamtailor.com/jobs.json" - req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "application/json"}) - with urllib.request.urlopen(req, timeout=30, context=_ssl_context()) as resp: - data = json.loads(resp.read().decode("utf-8", "replace")) + if args.get("base_url"): + url = args["base_url"].rstrip("/") + "/jobs.json" + else: + url = f"https://{args['slug']}.teamtailor.com/jobs.json" jobs = [] - for it in data.get("items", []): - jobs.append({ - "id": it.get("id") or it.get("url", ""), - "title": it.get("title", ""), - "location": args.get("default_location", ""), - "url": it.get("url", ""), - "posted": it.get("date_published", ""), - "description": re.sub(r"<[^>]+>", " ", it.get("content_html", ""))[:2500], - }) + for _ in range(args.get("max_pages", 10)): + req = urllib.request.Request( + url, headers={"User-Agent": USER_AGENT, "Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=30, context=_ssl_context()) as resp: + data = json.loads(resp.read().decode("utf-8", "replace")) + items = data.get("items", []) or [] + for it in items: + # Prefer the schema.org JobPosting payload's real address over default_location. + # Axpo is a pan-European trader (Madrid, Milan, Warsaw, Germany, France as well as + # Baden/Zurich), so defaulting every role to the board's home country would mark + # ~380 non-Swiss roles CH-eligible — the same class of bug as Apple's forced + # "Switzerland" default. Telenor's feed has no _jobposting and falls back cleanly. + location = args.get("default_location", "") + places = ((it.get("_jobposting") or {}).get("jobLocation") or []) + if isinstance(places, dict): + places = [places] + parts = [] + for place in places: + addr = (place or {}).get("address") or {} + # schema.org gives ISO alpha-2 ("CH", "NO"); the location policy matches on + # full country names, so expand or the role reads as location-unknown. + country = _ISO2_COUNTRY.get((addr.get("addressCountry") or "").upper(), + addr.get("addressCountry")) + bit = ", ".join(x for x in (addr.get("addressLocality"), country) if x) + if bit and bit not in parts: + parts.append(bit) + if parts: + location = " | ".join(parts) + jobs.append({ + "id": it.get("id") or it.get("url", ""), + "title": it.get("title", ""), + "location": location, + "url": it.get("url", ""), + "posted": it.get("date_published", ""), + "description": re.sub(r"<[^>]+>", " ", it.get("content_html", ""))[:2500], + }) + nxt = data.get("next_url") + if not items or not nxt or nxt == url: + break + url = nxt titles = {j["title"].strip().lower() for j in jobs} if jobs and len(titles & DEMO_TITLES) >= 4: raise RuntimeError( @@ -729,6 +858,94 @@ def fetch_getro(args): return jobs +def fetch_amazon(args): + """amazon.jobs public search JSON. Covers Amazon + AWS, which share one board. + + `normalized_country_code[]` is the filter that actually works; the plain `country[]` + and `loc_query` params are ignored and silently return the global (US-heavy) set — + verified 2026-08-18, where `country[]=CHE` returned 6,837 mostly-Seattle hits against + 32 for the normalized form.""" + base = "https://www.amazon.jobs/en/search.json" + headers = {"User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")} + jobs, offset, page_size = [], 0, 100 + while True: + qs = urllib.parse.urlencode({ + "radius": "100mi", "offset": offset, "result_limit": page_size, + "sort": "recent", "base_query": args.get("base_query", ""), + }) + countries = "".join(f"&normalized_country_code[]={urllib.parse.quote(c)}" + for c in args.get("countries", ["CHE"])) + data = http_get_json(f"{base}?{qs}{countries}", headers=headers) + batch = data.get("jobs", []) or [] + for j in batch: + jobs.append({ + "id": str(j.get("id_icims") or j.get("id") or ""), + "title": j.get("title", ""), + "location": j.get("normalized_location") or j.get("location") or "", + "url": "https://www.amazon.jobs" + (j.get("job_path") or ""), + "posted": j.get("posted_date", ""), + "description": (j.get("description_short") or j.get("description") or "")[:2000], + }) + total = data.get("hits", 0) + offset += page_size + if not batch or offset >= total or offset >= args.get("max_results", 500): + break + return jobs + + +def fetch_phenom(args): + """Phenom People careers search (POST /widgets with ddoKey=refineSearch). Used by Roche. + + The public search-results page is a JS shell whose location filter silently fails: a + scrape of `?locationsearch=Switzerland` returns *recommendation-widget* cards (Shanghai, + Kyiv, Bogota) while the page state reports `no-results`. Verified 2026-08-18 — this is + why Roche contributed 0 Swiss roles for months while looking healthy. The underlying + refineSearch endpoint filters correctly (134 CH roles), so query it directly. + + `country_facet` is the value for the `country` facet (e.g. "Switzerland"). `ref_num` and + `page_id` are tenant constants visible in any /widgets POST from the careers site.""" + url = args["url"] + ref_num, page_id = args["ref_num"], args.get("page_id", "page11-ds") + lang = args.get("lang", "en_global") + page_size = args.get("page_size", 100) + headers = { + "Origin": "{0.scheme}://{0.netloc}".format(urllib.parse.urlsplit(url)), + "Referer": url, + # Phenom 403s the default library UA. + "User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"), + } + jobs, offset = [], 0 + while True: + data = http_get_json(url, method="POST", headers=headers, data={ + "lang": lang, "deviceType": "desktop", "country": "global", + "pageName": "search-results", "ddoKey": "refineSearch", + "sortBy": "", "subsearch": "", "from": offset, "jobs": True, "counts": True, + "all_fields": ["country", "state", "city", "category", "type"], + "size": page_size, "clearAll": False, "jdsource": "facets", + "isSliderEnable": False, "pageId": page_id, "siteType": "external", + "keywords": "", "global": True, "locationData": {}, + "selected_fields": {"country": [args["country_facet"]]}, + }) + res = data.get("refineSearch", {}) or {} + batch = (res.get("data", {}) or {}).get("jobs", []) or [] + for j in batch: + jobs.append({ + "id": str(j.get("jobId") or j.get("jobSeqNo") or ""), + "title": j.get("title", ""), + "location": j.get("location") or j.get("cityStateCountry") or "", + "url": j.get("applyUrl", ""), + "posted": (j.get("postedDate") or "")[:10], + "description": (j.get("descriptionTeaser") or "")[:2000], + }) + total = res.get("totalHits", 0) + offset += page_size + if not batch or offset >= total or offset >= args.get("max_results", 600): + break + return jobs + + def fetch_onlyfy(args): """onlyfy.jobs board (XING E-Recruiting / ex-Prinzip), used by Bitcoin Suisse. The candidate/job/ajax_list endpoint returns an HTML fragment listing every posting; each @@ -1313,6 +1530,8 @@ ADAPTERS = { "rss": fetch_rss, "teamtailor": fetch_teamtailor, "getro": fetch_getro, + "amazon": fetch_amazon, + "phenom": fetch_phenom, "onlyfy": fetch_onlyfy, "lever": fetch_lever, "taleo": fetch_taleo, @@ -1611,10 +1830,16 @@ def _process_company(cid, display, args, jobs, seen, today, last_scrape): "0 jobs returned (verify board slug/selectors if this is unexpected)", )) + # Inclusion allowlist (fails closed — only for boards too large to score in full). title_filter = args.get("_title_filter") if title_filter: jobs = [j for j in jobs if any(_kw_in(k, (j.get("title") or "").lower()) for k in title_filter)] + # Exclusion denylist (fails open — preferred; see NOISE_TITLE_EXCLUDE). + title_exclude = args.get("_title_exclude") + if title_exclude: + jobs = [j for j in jobs + if not any(_kw_in(k, (j.get("title") or "").lower()) for k in title_exclude)] dates = [d for j in jobs if (d := _parse_posted(j.get("posted")))] newest = max(dates) if dates else None @@ -1828,6 +2053,9 @@ def main(): # pcsx microsoft # smartrecruiters metgroup, ldc # rss bis +# amazon amazon (Amazon + AWS share one board) +# phenom roche (Cisco still uses the playwright path) +# teamtailor telenor, axpo (custom domain via base_url) # getro (no company configured — adapter kept for future VC talent networks) # onlyfy bitcoin_suisse # lever palantir, quantco @@ -1835,7 +2063,7 @@ def main(): # json swissgrid # sbb sbb # bkw bkw -# playwright google, apple, meta, roche, cisco, ruag, postfinance, bfh +# playwright google, apple, meta, cisco, ruag, postfinance, bfh # # MANUAL_CHECK: Oracle (ORC needs CH geographyId). # ==============================================================================