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:
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
+268
-40
@@ -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"}
|
||||||
url = f"https://{args['slug']}.teamtailor.com/jobs.json"
|
if args.get("base_url"):
|
||||||
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "application/json"})
|
url = args["base_url"].rstrip("/") + "/jobs.json"
|
||||||
with urllib.request.urlopen(req, timeout=30, context=_ssl_context()) as resp:
|
else:
|
||||||
data = json.loads(resp.read().decode("utf-8", "replace"))
|
url = f"https://{args['slug']}.teamtailor.com/jobs.json"
|
||||||
jobs = []
|
jobs = []
|
||||||
for it in data.get("items", []):
|
for _ in range(args.get("max_pages", 10)):
|
||||||
jobs.append({
|
req = urllib.request.Request(
|
||||||
"id": it.get("id") or it.get("url", ""),
|
url, headers={"User-Agent": USER_AGENT, "Accept": "application/json"})
|
||||||
"title": it.get("title", ""),
|
with urllib.request.urlopen(req, timeout=30, context=_ssl_context()) as resp:
|
||||||
"location": args.get("default_location", ""),
|
data = json.loads(resp.read().decode("utf-8", "replace"))
|
||||||
"url": it.get("url", ""),
|
items = data.get("items", []) or []
|
||||||
"posted": it.get("date_published", ""),
|
for it in items:
|
||||||
"description": re.sub(r"<[^>]+>", " ", it.get("content_html", ""))[:2500],
|
# 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}
|
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).
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user