fix(scout): repair Roche/Apple, add Amazon+Axpo, make title filtering fail-open

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 11:17:24 +02:00
co-authored by Claude Opus 5
parent 082a1d250c
commit 284407cd23
8 changed files with 574 additions and 41 deletions
+70 -1
View File
@@ -1,6 +1,10 @@
# Scout roster review — 2026-08-18 # 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. 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, 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. 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. 5. **Decide on MET/LDC and the Norway three** — a judgment call, not a bug.
6. Probe CERN, Swiss Post, NATO NCIA, SIX. 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.
+20
View File
@@ -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()
+82
View File
@@ -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
+33
View File
@@ -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
+31
View File
@@ -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()
+21
View File
@@ -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()
+49
View File
@@ -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
+259 -31
View File
@@ -138,6 +138,9 @@ NEGATIVE_KEYWORDS = {
# post mostly non-tech roles). Only keep titles containing one of these specific role # 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" # phrases — kept tight so "Sales Engineer"/"Staff Accountant"/"Data Privacy Counsel"
# don't leak in. Matched as case-insensitive substrings against the title only. # 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 = [ ENG_TITLE_FILTER = [
"data engineer", "data engineering", "data platform", "platform engineer", "data engineer", "data engineering", "data platform", "platform engineer",
"data infrastructure", "data architect", "analytics 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 # "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. # all pre-sales SAs (the overscoring trap); "customer engineer" is Google's field-eng term.
"forward deployed", "forward-deployed", "field engineer", "resident", "customer engineer", "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 # id, display, adapter, adapter_args
COMPANIES = [ COMPANIES = [
("nvidia", "NVIDIA", "workday", { ("nvidia", "NVIDIA", "workday", {
@@ -167,6 +214,7 @@ COMPANIES = [
"tenant": "novartis", "tenant": "novartis",
"site": "Novartis_Careers", "site": "Novartis_Careers",
"search_text": "Switzerland", "search_text": "Switzerland",
"_title_exclude": NOISE_TITLE_EXCLUDE,
}), }),
# PCSX (Eightfold) — Microsoft has a public position search endpoint # PCSX (Eightfold) — Microsoft has a public position search endpoint
("microsoft", "Microsoft", "pcsx", { ("microsoft", "Microsoft", "pcsx", {
@@ -177,9 +225,9 @@ COMPANIES = [
# Dropped: ClickHouse (Glassdoor 3.3, 36% recommend, toxic-culture flag — 2026-05). # 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, # 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. # roles folded into IBM's careers (no clean public ATS API). 2026-06-06.
("confluent", "Confluent", "ashby", {"slug": "confluent", "_title_filter": ENG_TITLE_FILTER}), ("confluent", "Confluent", "ashby", {"slug": "confluent", "_title_exclude": NOISE_TITLE_EXCLUDE}),
("gitlab", "GitLab", "greenhouse", {"board": "gitlab", "_title_filter": ENG_TITLE_FILTER}), ("gitlab", "GitLab", "greenhouse", {"board": "gitlab", "_title_exclude": NOISE_TITLE_EXCLUDE}),
("grafana", "Grafana Labs","greenhouse",{"board": "grafanalabs", "_title_filter": ENG_TITLE_FILTER}), ("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 # 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 — # (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). # 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) --- # --- Energy / commodity trading (SmartRecruiters; title-filtered to tech roles) ---
# Dropped: Vitol (Glassdoor 3.5, 55% recommend, grueling-hours/toxic flag — 2026-05). # 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). # 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 <slug>.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}), ("ldc", "Louis Dreyfus","smartrecruiters",{"company": "LouisDreyfusCompany", "_title_filter": ENG_TITLE_FILTER}),
# Equinor (Workday) — Norway energy major; lived/worked in NO before. Outlier location # 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. # policy: Norway only (not CH / Europe-remote). Small board (~15); no title filter.
@@ -277,6 +335,20 @@ COMPANIES = [
"page_param_start": 2, "page_param_start": 2,
"max_pages": 6, "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", { ("apple", "Apple", "playwright", {
"url": "https://jobs.apple.com/en-us/search?location=switzerland-CHE", "url": "https://jobs.apple.com/en-us/search?location=switzerland-CHE",
"wait_for": "a[href*='/en-us/details/']", "wait_for": "a[href*='/en-us/details/']",
@@ -284,9 +356,19 @@ COMPANIES = [
"title_attr": "text", "title_attr": "text",
"link_attr": "href", "link_attr": "href",
"url_prefix": "https://jobs.apple.com", "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/<id>; title + location are in the link text. # Meta job links are /profile/job_details/<id>; 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", { ("meta", "Meta", "playwright", {
"url": "https://www.metacareers.com/jobs?offices[0]=Zurich%2C%20Switzerland", "url": "https://www.metacareers.com/jobs?offices[0]=Zurich%2C%20Switzerland",
"wait_for": "a[href*='/profile/job_details/']", "wait_for": "a[href*='/profile/job_details/']",
@@ -298,22 +380,18 @@ COMPANIES = [
"scroll_count": 5, "scroll_count": 5,
"use_inner_text_as_blob": True, "use_inner_text_as_blob": True,
}), }),
# PhenomPeople pattern (Roche) uses li.jobs-list-item. # Roche: was a playwright scrape of the search-results page until 2026-08-18. That page's
# Card inner text is structured like: "<title> | Location | <city, country> | Category | ..." # ?locationsearch=Switzerland filter silently fails — the page state reports "no-results"
# We extract title from first line, full text becomes the "description" so our location # and the scrape harvested *recommendation-widget* cards instead, returning Shanghai,
# filter still sees Switzerland mentions. # Kyiv, Bogota and Mannheim and zero Swiss roles for months while looking healthy. The
("roche", "Roche", "playwright", { # underlying Phenom refineSearch endpoint filters correctly (134 CH roles), so hit it
"url": "https://careers.roche.com/global/en/search-results?keywords=&locationsearch=Switzerland", # directly. Title-filtered: Roche CH is overwhelmingly lab/pharma/apprenticeship reqs.
"wait_for": "li.jobs-list-item, a.au-target", ("roche", "Roche", "phenom", {
"card": "li.jobs-list-item:not(:has-text('Saved jobs'))", "url": "https://careers.roche.com/widgets",
"title_attr": "text", "ref_num": "ROCHGLOBAL",
"link_sel": "a[href]", "page_id": "page11-ds",
"link_attr": "href", "country_facet": "Switzerland",
"url_prefix": "https://careers.roche.com", "_title_exclude": NOISE_TITLE_EXCLUDE,
"default_location": "",
"cookie_accept": ["#onetrust-accept-btn-handler", "button:has-text('Accept All Cookies')"],
"scroll_count": 6,
"use_inner_text_as_blob": True,
}), }),
# Cisco (PhenomPeople, new careers.cisco.com domain). Keyword search surfaces CH roles. # Cisco (PhenomPeople, new careers.cisco.com domain). Keyword search surfaces CH roles.
("cisco", "Cisco", "playwright", { ("cisco", "Cisco", "playwright", {
@@ -353,6 +431,7 @@ COMPANIES = [
"field_url": "descriptionUrl", "field_date": "onlineSince", "field_url": "descriptionUrl", "field_date": "onlineSince",
"loc_suffix": " Switzerland", "loc_suffix": " Switzerland",
"desc_keys": ["department", "typeOfEmployment", "entryLevel"], "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 # 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 # line of each anchor is the title. All sites are Swiss, so default_location=Switzerland
@@ -370,7 +449,7 @@ COMPANIES = [
"scroll_count": 1, "scroll_count": 1,
"page_param": "page", "page_param": "page",
"max_pages": 10, "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 # 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 # 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", "topic": "IT / Telekommunikation",
"region": "Bern Mittelland", "region": "Bern Mittelland",
"_score_floor": 2, "_score_floor": 2,
"_title_exclude": NOISE_TITLE_EXCLUDE,
}), }),
# BKW Group (jobs.bkw.com — the real ATS host). PMS structured-data API; ~600 roles # 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 # group-wide, so fetch_bkw keeps only Berufsfeld categories Informatik/Trading/Finanzen
# (IT/data + energy-trading, incl. the flagged Energiehandel roles). German/generic # (IT/data + energy-trading, incl. the flagged Energiehandel roles). German/generic
# titles, so _score_floor keeps the pre-filtered set visible. # 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; # 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 # 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 # 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, "use_inner_text_as_blob": True,
"next_button": "#pfch-pagination-next", "next_button": "#pfch-pagination-next",
"max_pages": 10, "max_pages": 10,
"_title_exclude": NOISE_TITLE_EXCLUDE,
}), }),
# BFH (Bern University of Applied Sciences). Re-added 2026-07-14: the jobs.bfh.ch domain # 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 # itself is a broken/stub SPA shell (renders "Career Center project template", nothing
@@ -419,6 +501,7 @@ COMPANIES = [
"link_sel": "a", "link_sel": "a",
"default_location": "Switzerland", "default_location": "Switzerland",
"_score_floor": 2, "_score_floor": 2,
"_title_exclude": NOISE_TITLE_EXCLUDE,
}), }),
] ]
@@ -644,34 +727,80 @@ def fetch_rss(args):
return jobs 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): def fetch_teamtailor(args):
"""Teamtailor public JSON Feed (`https://<slug>.teamtailor.com/jobs.json`). """Teamtailor public JSON Feed (`https://<slug>.teamtailor.com/jobs.json`).
Used by Telenor. The public /jobs HTML page paginates and under-reports; the feed Used by Telenor (`slug`) and Axpo (`base_url`, a Teamtailor board on a custom domain —
returns the full board in one call, so prefer it. The feed carries no location careers.axpo.com — which no <slug>.teamtailor.com host resolves to). The public /jobs
field, so default_location is required (Teamtailor boards are per-country anyway). 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. 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) 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 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 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.""" 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", DEMO_TITLES = {"sales development manager", "team lead - csm", "social media manager",
"customer success manager", "key account manager", "ux designer"} "customer success manager", "key account manager", "ux designer"}
if args.get("base_url"):
url = args["base_url"].rstrip("/") + "/jobs.json"
else:
url = f"https://{args['slug']}.teamtailor.com/jobs.json" url = f"https://{args['slug']}.teamtailor.com/jobs.json"
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "application/json"}) jobs = []
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: with urllib.request.urlopen(req, timeout=30, context=_ssl_context()) as resp:
data = json.loads(resp.read().decode("utf-8", "replace")) data = json.loads(resp.read().decode("utf-8", "replace"))
jobs = [] items = data.get("items", []) or []
for it in data.get("items", []): 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({ jobs.append({
"id": it.get("id") or it.get("url", ""), "id": it.get("id") or it.get("url", ""),
"title": it.get("title", ""), "title": it.get("title", ""),
"location": args.get("default_location", ""), "location": location,
"url": it.get("url", ""), "url": it.get("url", ""),
"posted": it.get("date_published", ""), "posted": it.get("date_published", ""),
"description": re.sub(r"<[^>]+>", " ", it.get("content_html", ""))[:2500], "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} titles = {j["title"].strip().lower() for j in jobs}
if jobs and len(titles & DEMO_TITLES) >= 4: if jobs and len(titles & DEMO_TITLES) >= 4:
raise RuntimeError( raise RuntimeError(
@@ -729,6 +858,94 @@ def fetch_getro(args):
return jobs 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): def fetch_onlyfy(args):
"""onlyfy.jobs board (XING E-Recruiting / ex-Prinzip), used by Bitcoin Suisse. The """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 candidate/job/ajax_list endpoint returns an HTML fragment listing every posting; each
@@ -1313,6 +1530,8 @@ ADAPTERS = {
"rss": fetch_rss, "rss": fetch_rss,
"teamtailor": fetch_teamtailor, "teamtailor": fetch_teamtailor,
"getro": fetch_getro, "getro": fetch_getro,
"amazon": fetch_amazon,
"phenom": fetch_phenom,
"onlyfy": fetch_onlyfy, "onlyfy": fetch_onlyfy,
"lever": fetch_lever, "lever": fetch_lever,
"taleo": fetch_taleo, "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)", "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") title_filter = args.get("_title_filter")
if title_filter: if title_filter:
jobs = [j for j in jobs jobs = [j for j in jobs
if any(_kw_in(k, (j.get("title") or "").lower()) for k in title_filter)] 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")))] dates = [d for j in jobs if (d := _parse_posted(j.get("posted")))]
newest = max(dates) if dates else None newest = max(dates) if dates else None
@@ -1828,6 +2053,9 @@ def main():
# pcsx microsoft # pcsx microsoft
# smartrecruiters metgroup, ldc # smartrecruiters metgroup, ldc
# rss bis # 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) # getro (no company configured — adapter kept for future VC talent networks)
# onlyfy bitcoin_suisse # onlyfy bitcoin_suisse
# lever palantir, quantco # lever palantir, quantco
@@ -1835,7 +2063,7 @@ def main():
# json swissgrid # json swissgrid
# sbb sbb # sbb sbb
# bkw bkw # 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). # MANUAL_CHECK: Oracle (ORC needs CH geographyId).
# ============================================================================== # ==============================================================================