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
+268 -40
View File
@@ -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).
# ==============================================================================