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:
+268
-40
@@ -138,6 +138,9 @@ NEGATIVE_KEYWORDS = {
|
||||
# post mostly non-tech roles). Only keep titles containing one of these specific role
|
||||
# phrases — kept tight so "Sales Engineer"/"Staff Accountant"/"Data Privacy Counsel"
|
||||
# don't leak in. Matched as case-insensitive substrings against the title only.
|
||||
# Inclusion allowlist. Applied ONLY to boards too large to score in full (>~200 roles:
|
||||
# Databricks, Snowflake, Datadog, Elastic, Fivetran, Louis Dreyfus, Palantir). Everywhere else
|
||||
# use NOISE_TITLE_EXCLUDE — see the design note there for why fail-open is the default.
|
||||
ENG_TITLE_FILTER = [
|
||||
"data engineer", "data engineering", "data platform", "platform engineer",
|
||||
"data infrastructure", "data architect", "analytics engineer",
|
||||
@@ -149,8 +152,52 @@ ENG_TITLE_FILTER = [
|
||||
# "resident" alone catches Resident Solutions Architect/Engineer without opening the gate to
|
||||
# all pre-sales SAs (the overscoring trap); "customer engineer" is Google's field-eng term.
|
||||
"forward deployed", "forward-deployed", "field engineer", "resident", "customer engineer",
|
||||
# Added 2026-08-18 after the CH boards were title-filtered: without these, real near-misses
|
||||
# were dropped (BFH "Wissenschaftliche Mitarbeit Data-Driven Government", Novartis "Director
|
||||
# & Group Head (AI-Systems & Scale)"). Deliberately NOT adding bare "ai" — it substring-matches
|
||||
# Maintenance/Training/Chair and floods every board.
|
||||
"artificial intelligence", "ai-systems", "ai platform", "ai engineer", "ai architect",
|
||||
"data scientist", "data-driven", "data science",
|
||||
]
|
||||
|
||||
# Generic non-tech title exclusions, shared by every board that uses exclusion-mode filtering.
|
||||
#
|
||||
# DESIGN NOTE (2026-08-18). Boards are filtered one of two ways:
|
||||
# * inclusion (`_title_filter`) — an allowlist, used ONLY on boards too large to score in
|
||||
# full (Databricks ~800, OpenAI ~730, Palantir ~300). It fails CLOSED: a great-fit role
|
||||
# with a title nobody anticipated is dropped at fetch time and never reaches the scorer,
|
||||
# so it appears in no report and in no JSON dump. That is an acceptable trade only where
|
||||
# volume forces it.
|
||||
# * exclusion (`_title_exclude`) — this list. It fails OPEN: everything survives unless it
|
||||
# is *clearly* not an engineering role, and the score + the weak/noise bucket decide what
|
||||
# surfaces. Prefer this. The final call belongs to the reviewer, not to a keyword gate.
|
||||
#
|
||||
# Keep these terms unambiguous. Anything that could plausibly attach to a technical role
|
||||
# (analyst, manager, specialist, consultant, architect, lead) must NOT go in here.
|
||||
NOISE_TITLE_EXCLUDE = [
|
||||
# Facilities / retail / hospitality / admin
|
||||
"hausdienst", "reinigung", "empfang", "hauswart", "chef de partie", "koch", "küche",
|
||||
"restaurant", "catering", "fahrer", "logistik mitarbeiter", "lagerist", "verkauf",
|
||||
"verkäufer", "retail", "barista", "security guard", "sicherheitsdienst",
|
||||
# Care / health / teaching-of-non-tech
|
||||
"physiotherapie", "pflege", "pflegefach", "hebamme", "ergotherapie", "psychologie",
|
||||
"medical representative", "nurse", "arzt", "ärztin", "dentist",
|
||||
# Arts / music / sport
|
||||
"violine", "klavier", "musik", "dozierende*r violine", "sport",
|
||||
# Back-office
|
||||
"fundraising", "buchhaltung", "accountant", "accounting", "payroll", "steuer",
|
||||
"recruiter", "talent acquisition", "human resources", "personalwesen",
|
||||
"legal counsel", "rechtsanwalt", "rechtsreferendar", "notar", "jurist",
|
||||
"kommunikation", "public relations", "übersetzer", "translator",
|
||||
# Early-career / non-role listings
|
||||
"lehrstelle", "praktikum", "praktikant", "internship", "intern ", "trainee",
|
||||
"apprenti", "ausbildung", "schnupper", "where we're hiring", "talent community",
|
||||
# Non-software engineering trades
|
||||
"elektroplaner", "elektroinstallat", "sanitär", "heizung", "maler", "schreiner",
|
||||
"hochspannung", "wasserbau", "strassenbau", "holzbau", "bauingenieur", "bauleiter",
|
||||
]
|
||||
|
||||
|
||||
# id, display, adapter, adapter_args
|
||||
COMPANIES = [
|
||||
("nvidia", "NVIDIA", "workday", {
|
||||
@@ -167,6 +214,7 @@ COMPANIES = [
|
||||
"tenant": "novartis",
|
||||
"site": "Novartis_Careers",
|
||||
"search_text": "Switzerland",
|
||||
"_title_exclude": NOISE_TITLE_EXCLUDE,
|
||||
}),
|
||||
# PCSX (Eightfold) — Microsoft has a public position search endpoint
|
||||
("microsoft", "Microsoft", "pcsx", {
|
||||
@@ -177,9 +225,9 @@ COMPANIES = [
|
||||
# Dropped: ClickHouse (Glassdoor 3.3, 36% recommend, toxic-culture flag — 2026-05).
|
||||
# Dropped: HashiCorp — acquired by IBM (closed 2025); greenhouse/ashby/lever boards all 404,
|
||||
# roles folded into IBM's careers (no clean public ATS API). 2026-06-06.
|
||||
("confluent", "Confluent", "ashby", {"slug": "confluent", "_title_filter": ENG_TITLE_FILTER}),
|
||||
("gitlab", "GitLab", "greenhouse", {"board": "gitlab", "_title_filter": ENG_TITLE_FILTER}),
|
||||
("grafana", "Grafana Labs","greenhouse",{"board": "grafanalabs", "_title_filter": ENG_TITLE_FILTER}),
|
||||
("confluent", "Confluent", "ashby", {"slug": "confluent", "_title_exclude": NOISE_TITLE_EXCLUDE}),
|
||||
("gitlab", "GitLab", "greenhouse", {"board": "gitlab", "_title_exclude": NOISE_TITLE_EXCLUDE}),
|
||||
("grafana", "Grafana Labs","greenhouse",{"board": "grafanalabs", "_title_exclude": NOISE_TITLE_EXCLUDE}),
|
||||
# Added 2026-06-06 (Tier A/B data-infra). Databricks/Snowflake/Datadog have Zürich offices
|
||||
# (Swiss-scale comp, clears bar); Elastic/Fivetran are remote-EU (verify CH-equiv comp —
|
||||
# may be geo-banded below 180k, like Grafana). All title-filtered (boards are 160-760 roles).
|
||||
@@ -195,7 +243,17 @@ COMPANIES = [
|
||||
# --- Energy / commodity trading (SmartRecruiters; title-filtered to tech roles) ---
|
||||
# Dropped: Vitol (Glassdoor 3.5, 55% recommend, grueling-hours/toxic flag — 2026-05).
|
||||
# Dropped: Sygnum (Glassdoor 3.4, 51% recommend, comp 2.3/5 — below 180k bar — 2026-05).
|
||||
("metgroup", "MET Group", "smartrecruiters", {"company": "METGroup", "_title_filter": ENG_TITLE_FILTER}),
|
||||
# Axpo (Baden/Zurich) — Teamtailor on a custom domain; no <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}),
|
||||
# Equinor (Workday) — Norway energy major; lived/worked in NO before. Outlier location
|
||||
# policy: Norway only (not CH / Europe-remote). Small board (~15); no title filter.
|
||||
@@ -277,6 +335,20 @@ COMPANIES = [
|
||||
"page_param_start": 2,
|
||||
"max_pages": 6,
|
||||
}),
|
||||
# Amazon + AWS share one board. Added 2026-08-18: AWS is the cloud the evidence base
|
||||
# actually supports (Swisscom migration + data products), whereas claims.json marks GCP
|
||||
# output-forbidden — so an AWS-native Zurich FDE req is the Google FDE lane without the
|
||||
# cloud gap. 32 CH roles at time of adding, incl. "Senior Forward Deployed Engineer, AWS
|
||||
# Forward Deployed Engineering" (Zurich).
|
||||
# AWS names its delivery/field org differently from every other board, so the shared
|
||||
# filter caught only 1 of 32 CH roles. These four terms are added narrowly and ONLY here:
|
||||
# "solutions architect" is the classic overscoring trap (see job_scout_overscoring_findings),
|
||||
# but on the AWS board it is the delivery-side title attached to migration/data work, which
|
||||
# is exactly SW-1. Read the JD before trusting the score on any of these.
|
||||
("amazon", "Amazon / AWS", "amazon", {
|
||||
"countries": ["CHE"],
|
||||
"_title_exclude": NOISE_TITLE_EXCLUDE,
|
||||
}),
|
||||
("apple", "Apple", "playwright", {
|
||||
"url": "https://jobs.apple.com/en-us/search?location=switzerland-CHE",
|
||||
"wait_for": "a[href*='/en-us/details/']",
|
||||
@@ -284,9 +356,19 @@ COMPANIES = [
|
||||
"title_attr": "text",
|
||||
"link_attr": "href",
|
||||
"url_prefix": "https://jobs.apple.com",
|
||||
"default_location": "Switzerland",
|
||||
# NO default_location. Apple's own postLocation-CHE filter leaks global reqs whose
|
||||
# postLocationId is postLocation-USA ("Various Locations within United States"), and
|
||||
# a forced "Switzerland" default relabelled every one of them as CH-eligible — 84
|
||||
# phantom CH rows over 4 runs, all internships/retail, which then read as "Apple is a
|
||||
# bad fit" in the decision log. Read the real location off the card instead; when
|
||||
# Apple has no Swiss reqs the honest answer is 0. Verified 2026-08-18.
|
||||
"use_inner_text_as_blob": True,
|
||||
"scroll_count": 5,
|
||||
}),
|
||||
# Meta job links are /profile/job_details/<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", {
|
||||
"url": "https://www.metacareers.com/jobs?offices[0]=Zurich%2C%20Switzerland",
|
||||
"wait_for": "a[href*='/profile/job_details/']",
|
||||
@@ -298,22 +380,18 @@ COMPANIES = [
|
||||
"scroll_count": 5,
|
||||
"use_inner_text_as_blob": True,
|
||||
}),
|
||||
# PhenomPeople pattern (Roche) uses li.jobs-list-item.
|
||||
# Card inner text is structured like: "<title> | Location | <city, country> | Category | ..."
|
||||
# We extract title from first line, full text becomes the "description" so our location
|
||||
# filter still sees Switzerland mentions.
|
||||
("roche", "Roche", "playwright", {
|
||||
"url": "https://careers.roche.com/global/en/search-results?keywords=&locationsearch=Switzerland",
|
||||
"wait_for": "li.jobs-list-item, a.au-target",
|
||||
"card": "li.jobs-list-item:not(:has-text('Saved jobs'))",
|
||||
"title_attr": "text",
|
||||
"link_sel": "a[href]",
|
||||
"link_attr": "href",
|
||||
"url_prefix": "https://careers.roche.com",
|
||||
"default_location": "",
|
||||
"cookie_accept": ["#onetrust-accept-btn-handler", "button:has-text('Accept All Cookies')"],
|
||||
"scroll_count": 6,
|
||||
"use_inner_text_as_blob": True,
|
||||
# Roche: was a playwright scrape of the search-results page until 2026-08-18. That page's
|
||||
# ?locationsearch=Switzerland filter silently fails — the page state reports "no-results"
|
||||
# and the scrape harvested *recommendation-widget* cards instead, returning Shanghai,
|
||||
# Kyiv, Bogota and Mannheim and zero Swiss roles for months while looking healthy. The
|
||||
# underlying Phenom refineSearch endpoint filters correctly (134 CH roles), so hit it
|
||||
# directly. Title-filtered: Roche CH is overwhelmingly lab/pharma/apprenticeship reqs.
|
||||
("roche", "Roche", "phenom", {
|
||||
"url": "https://careers.roche.com/widgets",
|
||||
"ref_num": "ROCHGLOBAL",
|
||||
"page_id": "page11-ds",
|
||||
"country_facet": "Switzerland",
|
||||
"_title_exclude": NOISE_TITLE_EXCLUDE,
|
||||
}),
|
||||
# Cisco (PhenomPeople, new careers.cisco.com domain). Keyword search surfaces CH roles.
|
||||
("cisco", "Cisco", "playwright", {
|
||||
@@ -353,6 +431,7 @@ COMPANIES = [
|
||||
"field_url": "descriptionUrl", "field_date": "onlineSince",
|
||||
"loc_suffix": " Switzerland",
|
||||
"desc_keys": ["department", "typeOfEmployment", "entryLevel"],
|
||||
"_title_exclude": NOISE_TITLE_EXCLUDE,
|
||||
}),
|
||||
# RUAG (Thun/Bern/Emmen). Jobs render on the portal as anchors to jobs.ruag.ch; the first
|
||||
# line of each anchor is the title. All sites are Swiss, so default_location=Switzerland
|
||||
@@ -370,7 +449,7 @@ COMPANIES = [
|
||||
"scroll_count": 1,
|
||||
"page_param": "page",
|
||||
"max_pages": 10,
|
||||
"_title_filter": ENG_TITLE_FILTER,
|
||||
"_title_exclude": NOISE_TITLE_EXCLUDE,
|
||||
}),
|
||||
# SBB (company.sbb.ch — the correct host; company-jobs.sbb.ch was wrong). AEM job filter
|
||||
# served as a flat JSON list; the fetch_sbb adapter replicates the user's IT + Bern-region
|
||||
@@ -380,12 +459,14 @@ COMPANIES = [
|
||||
"topic": "IT / Telekommunikation",
|
||||
"region": "Bern Mittelland",
|
||||
"_score_floor": 2,
|
||||
"_title_exclude": NOISE_TITLE_EXCLUDE,
|
||||
}),
|
||||
# BKW Group (jobs.bkw.com — the real ATS host). PMS structured-data API; ~600 roles
|
||||
# group-wide, so fetch_bkw keeps only Berufsfeld categories Informatik/Trading/Finanzen
|
||||
# (IT/data + energy-trading, incl. the flagged Energiehandel roles). German/generic
|
||||
# titles, so _score_floor keeps the pre-filtered set visible.
|
||||
("bkw", "BKW (Bern)", "bkw", {"_score_floor": 2}),
|
||||
("bkw", "BKW (Bern)", "bkw", {"_score_floor": 2,
|
||||
"_title_exclude": NOISE_TITLE_EXCLUDE}),
|
||||
# PostFinance (Bern). The careers site renders a small, client-side paginated board;
|
||||
# scrape all pages through its stable next-page control. No title filter: the board is
|
||||
# low-volume, and the scorer keeps unrelated banking/customer-service roles out of the
|
||||
@@ -400,6 +481,7 @@ COMPANIES = [
|
||||
"use_inner_text_as_blob": True,
|
||||
"next_button": "#pfch-pagination-next",
|
||||
"max_pages": 10,
|
||||
"_title_exclude": NOISE_TITLE_EXCLUDE,
|
||||
}),
|
||||
# BFH (Bern University of Applied Sciences). Re-added 2026-07-14: the jobs.bfh.ch domain
|
||||
# itself is a broken/stub SPA shell (renders "Career Center project template", nothing
|
||||
@@ -419,6 +501,7 @@ COMPANIES = [
|
||||
"link_sel": "a",
|
||||
"default_location": "Switzerland",
|
||||
"_score_floor": 2,
|
||||
"_title_exclude": NOISE_TITLE_EXCLUDE,
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -644,34 +727,80 @@ def fetch_rss(args):
|
||||
return jobs
|
||||
|
||||
|
||||
# schema.org JobPosting addresses carry ISO alpha-2 country codes; the location policy
|
||||
# keyword lists are full names. Only the countries the policies actually test for.
|
||||
_ISO2_COUNTRY = {
|
||||
"CH": "Switzerland", "NO": "Norway", "DK": "Denmark", "DE": "Germany",
|
||||
"AT": "Austria", "FR": "France", "IT": "Italy", "ES": "Spain", "PL": "Poland",
|
||||
"NL": "Netherlands", "BE": "Belgium", "SE": "Sweden", "FI": "Finland",
|
||||
"GB": "United Kingdom", "UK": "United Kingdom", "IE": "Ireland", "US": "United States",
|
||||
"PT": "Portugal", "CZ": "Czechia", "RO": "Romania", "RS": "Serbia", "TR": "Turkey",
|
||||
}
|
||||
|
||||
|
||||
def fetch_teamtailor(args):
|
||||
"""Teamtailor public JSON Feed (`https://<slug>.teamtailor.com/jobs.json`).
|
||||
|
||||
Used by Telenor. The public /jobs HTML page paginates and under-reports; the feed
|
||||
returns the full board in one call, so prefer it. The feed carries no location
|
||||
field, so default_location is required (Teamtailor boards are per-country anyway).
|
||||
Used by Telenor (`slug`) and Axpo (`base_url`, a Teamtailor board on a custom domain —
|
||||
careers.axpo.com — which no <slug>.teamtailor.com host resolves to). The public /jobs
|
||||
HTML page paginates and under-reports; the feed is authoritative. The feed carries no
|
||||
location field, so default_location is required (Teamtailor boards are per-country).
|
||||
Descriptions are frequently Norwegian — see the _score_floor note on the company.
|
||||
|
||||
The feed caps at 100 items and links the next page via `next_url`; follow it, or large
|
||||
boards silently truncate (Axpo is ~400 roles, i.e. 4 pages).
|
||||
|
||||
TRAP: an unclaimed slug returns Teamtailor's *demo* board (HTTP 200, plausible JSON)
|
||||
instead of 404. Verified 2026-07-29: `ksat` and `akerbp` both served the seed set
|
||||
below though neither company uses Teamtailor. Filtered here so phantom roles never
|
||||
reach a report — if a real board is ever dropped by this, widen the fingerprint."""
|
||||
DEMO_TITLES = {"sales development manager", "team lead - csm", "social media manager",
|
||||
"customer success manager", "key account manager", "ux designer"}
|
||||
url = f"https://{args['slug']}.teamtailor.com/jobs.json"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=30, context=_ssl_context()) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8", "replace"))
|
||||
if args.get("base_url"):
|
||||
url = args["base_url"].rstrip("/") + "/jobs.json"
|
||||
else:
|
||||
url = f"https://{args['slug']}.teamtailor.com/jobs.json"
|
||||
jobs = []
|
||||
for it in data.get("items", []):
|
||||
jobs.append({
|
||||
"id": it.get("id") or it.get("url", ""),
|
||||
"title": it.get("title", ""),
|
||||
"location": args.get("default_location", ""),
|
||||
"url": it.get("url", ""),
|
||||
"posted": it.get("date_published", ""),
|
||||
"description": re.sub(r"<[^>]+>", " ", it.get("content_html", ""))[:2500],
|
||||
})
|
||||
for _ in range(args.get("max_pages", 10)):
|
||||
req = urllib.request.Request(
|
||||
url, headers={"User-Agent": USER_AGENT, "Accept": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=30, context=_ssl_context()) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8", "replace"))
|
||||
items = data.get("items", []) or []
|
||||
for it in items:
|
||||
# Prefer the schema.org JobPosting payload's real address over default_location.
|
||||
# Axpo is a pan-European trader (Madrid, Milan, Warsaw, Germany, France as well as
|
||||
# Baden/Zurich), so defaulting every role to the board's home country would mark
|
||||
# ~380 non-Swiss roles CH-eligible — the same class of bug as Apple's forced
|
||||
# "Switzerland" default. Telenor's feed has no _jobposting and falls back cleanly.
|
||||
location = args.get("default_location", "")
|
||||
places = ((it.get("_jobposting") or {}).get("jobLocation") or [])
|
||||
if isinstance(places, dict):
|
||||
places = [places]
|
||||
parts = []
|
||||
for place in places:
|
||||
addr = (place or {}).get("address") or {}
|
||||
# schema.org gives ISO alpha-2 ("CH", "NO"); the location policy matches on
|
||||
# full country names, so expand or the role reads as location-unknown.
|
||||
country = _ISO2_COUNTRY.get((addr.get("addressCountry") or "").upper(),
|
||||
addr.get("addressCountry"))
|
||||
bit = ", ".join(x for x in (addr.get("addressLocality"), country) if x)
|
||||
if bit and bit not in parts:
|
||||
parts.append(bit)
|
||||
if parts:
|
||||
location = " | ".join(parts)
|
||||
jobs.append({
|
||||
"id": it.get("id") or it.get("url", ""),
|
||||
"title": it.get("title", ""),
|
||||
"location": location,
|
||||
"url": it.get("url", ""),
|
||||
"posted": it.get("date_published", ""),
|
||||
"description": re.sub(r"<[^>]+>", " ", it.get("content_html", ""))[:2500],
|
||||
})
|
||||
nxt = data.get("next_url")
|
||||
if not items or not nxt or nxt == url:
|
||||
break
|
||||
url = nxt
|
||||
titles = {j["title"].strip().lower() for j in jobs}
|
||||
if jobs and len(titles & DEMO_TITLES) >= 4:
|
||||
raise RuntimeError(
|
||||
@@ -729,6 +858,94 @@ def fetch_getro(args):
|
||||
return jobs
|
||||
|
||||
|
||||
def fetch_amazon(args):
|
||||
"""amazon.jobs public search JSON. Covers Amazon + AWS, which share one board.
|
||||
|
||||
`normalized_country_code[]` is the filter that actually works; the plain `country[]`
|
||||
and `loc_query` params are ignored and silently return the global (US-heavy) set —
|
||||
verified 2026-08-18, where `country[]=CHE` returned 6,837 mostly-Seattle hits against
|
||||
32 for the normalized form."""
|
||||
base = "https://www.amazon.jobs/en/search.json"
|
||||
headers = {"User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")}
|
||||
jobs, offset, page_size = [], 0, 100
|
||||
while True:
|
||||
qs = urllib.parse.urlencode({
|
||||
"radius": "100mi", "offset": offset, "result_limit": page_size,
|
||||
"sort": "recent", "base_query": args.get("base_query", ""),
|
||||
})
|
||||
countries = "".join(f"&normalized_country_code[]={urllib.parse.quote(c)}"
|
||||
for c in args.get("countries", ["CHE"]))
|
||||
data = http_get_json(f"{base}?{qs}{countries}", headers=headers)
|
||||
batch = data.get("jobs", []) or []
|
||||
for j in batch:
|
||||
jobs.append({
|
||||
"id": str(j.get("id_icims") or j.get("id") or ""),
|
||||
"title": j.get("title", ""),
|
||||
"location": j.get("normalized_location") or j.get("location") or "",
|
||||
"url": "https://www.amazon.jobs" + (j.get("job_path") or ""),
|
||||
"posted": j.get("posted_date", ""),
|
||||
"description": (j.get("description_short") or j.get("description") or "")[:2000],
|
||||
})
|
||||
total = data.get("hits", 0)
|
||||
offset += page_size
|
||||
if not batch or offset >= total or offset >= args.get("max_results", 500):
|
||||
break
|
||||
return jobs
|
||||
|
||||
|
||||
def fetch_phenom(args):
|
||||
"""Phenom People careers search (POST /widgets with ddoKey=refineSearch). Used by Roche.
|
||||
|
||||
The public search-results page is a JS shell whose location filter silently fails: a
|
||||
scrape of `?locationsearch=Switzerland` returns *recommendation-widget* cards (Shanghai,
|
||||
Kyiv, Bogota) while the page state reports `no-results`. Verified 2026-08-18 — this is
|
||||
why Roche contributed 0 Swiss roles for months while looking healthy. The underlying
|
||||
refineSearch endpoint filters correctly (134 CH roles), so query it directly.
|
||||
|
||||
`country_facet` is the value for the `country` facet (e.g. "Switzerland"). `ref_num` and
|
||||
`page_id` are tenant constants visible in any /widgets POST from the careers site."""
|
||||
url = args["url"]
|
||||
ref_num, page_id = args["ref_num"], args.get("page_id", "page11-ds")
|
||||
lang = args.get("lang", "en_global")
|
||||
page_size = args.get("page_size", 100)
|
||||
headers = {
|
||||
"Origin": "{0.scheme}://{0.netloc}".format(urllib.parse.urlsplit(url)),
|
||||
"Referer": url,
|
||||
# Phenom 403s the default library UA.
|
||||
"User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"),
|
||||
}
|
||||
jobs, offset = [], 0
|
||||
while True:
|
||||
data = http_get_json(url, method="POST", headers=headers, data={
|
||||
"lang": lang, "deviceType": "desktop", "country": "global",
|
||||
"pageName": "search-results", "ddoKey": "refineSearch",
|
||||
"sortBy": "", "subsearch": "", "from": offset, "jobs": True, "counts": True,
|
||||
"all_fields": ["country", "state", "city", "category", "type"],
|
||||
"size": page_size, "clearAll": False, "jdsource": "facets",
|
||||
"isSliderEnable": False, "pageId": page_id, "siteType": "external",
|
||||
"keywords": "", "global": True, "locationData": {},
|
||||
"selected_fields": {"country": [args["country_facet"]]},
|
||||
})
|
||||
res = data.get("refineSearch", {}) or {}
|
||||
batch = (res.get("data", {}) or {}).get("jobs", []) or []
|
||||
for j in batch:
|
||||
jobs.append({
|
||||
"id": str(j.get("jobId") or j.get("jobSeqNo") or ""),
|
||||
"title": j.get("title", ""),
|
||||
"location": j.get("location") or j.get("cityStateCountry") or "",
|
||||
"url": j.get("applyUrl", ""),
|
||||
"posted": (j.get("postedDate") or "")[:10],
|
||||
"description": (j.get("descriptionTeaser") or "")[:2000],
|
||||
})
|
||||
total = res.get("totalHits", 0)
|
||||
offset += page_size
|
||||
if not batch or offset >= total or offset >= args.get("max_results", 600):
|
||||
break
|
||||
return jobs
|
||||
|
||||
|
||||
def fetch_onlyfy(args):
|
||||
"""onlyfy.jobs board (XING E-Recruiting / ex-Prinzip), used by Bitcoin Suisse. The
|
||||
candidate/job/ajax_list endpoint returns an HTML fragment listing every posting; each
|
||||
@@ -1313,6 +1530,8 @@ ADAPTERS = {
|
||||
"rss": fetch_rss,
|
||||
"teamtailor": fetch_teamtailor,
|
||||
"getro": fetch_getro,
|
||||
"amazon": fetch_amazon,
|
||||
"phenom": fetch_phenom,
|
||||
"onlyfy": fetch_onlyfy,
|
||||
"lever": fetch_lever,
|
||||
"taleo": fetch_taleo,
|
||||
@@ -1611,10 +1830,16 @@ def _process_company(cid, display, args, jobs, seen, today, last_scrape):
|
||||
"0 jobs returned (verify board slug/selectors if this is unexpected)",
|
||||
))
|
||||
|
||||
# Inclusion allowlist (fails closed — only for boards too large to score in full).
|
||||
title_filter = args.get("_title_filter")
|
||||
if title_filter:
|
||||
jobs = [j for j in jobs
|
||||
if any(_kw_in(k, (j.get("title") or "").lower()) for k in title_filter)]
|
||||
# Exclusion denylist (fails open — preferred; see NOISE_TITLE_EXCLUDE).
|
||||
title_exclude = args.get("_title_exclude")
|
||||
if title_exclude:
|
||||
jobs = [j for j in jobs
|
||||
if not any(_kw_in(k, (j.get("title") or "").lower()) for k in title_exclude)]
|
||||
|
||||
dates = [d for j in jobs if (d := _parse_posted(j.get("posted")))]
|
||||
newest = max(dates) if dates else None
|
||||
@@ -1828,6 +2053,9 @@ def main():
|
||||
# pcsx microsoft
|
||||
# smartrecruiters metgroup, ldc
|
||||
# rss bis
|
||||
# amazon amazon (Amazon + AWS share one board)
|
||||
# phenom roche (Cisco still uses the playwright path)
|
||||
# teamtailor telenor, axpo (custom domain via base_url)
|
||||
# getro (no company configured — adapter kept for future VC talent networks)
|
||||
# onlyfy bitcoin_suisse
|
||||
# lever palantir, quantco
|
||||
@@ -1835,7 +2063,7 @@ def main():
|
||||
# json swissgrid
|
||||
# sbb sbb
|
||||
# bkw bkw
|
||||
# playwright google, apple, meta, roche, cisco, ruag, postfinance, bfh
|
||||
# playwright google, apple, meta, cisco, ruag, postfinance, bfh
|
||||
#
|
||||
# MANUAL_CHECK: Oracle (ORC needs CH geographyId).
|
||||
# ==============================================================================
|
||||
|
||||
Reference in New Issue
Block a user