fix(scout): Apple blind to CH for 2 months, BIS behind Cloudflare, NAV rate limit, EMEA leak; reputation for every board

Apple (the big one)
- The location=switzerland-CHE slug stopped resolving; Apple silently applied
  a US location, so every run from at least 2026-08-31 returned the same 21
  US/intern cards and 0 Swiss roles. Now location code CHEC (from
  /api/v1/refData/postlocation), result list item as the card (location
  readable), pagination, settle delay, longer wait and per-element timeout.
  Verified twice: 24 Swiss roles each run.

BIS
- bis.org went behind a Cloudflare challenge (403 even headless). Moved to the
  Workday tenant bis.wd3.myworkdayjobs.com/External, which also carries the
  office. Host alias + lower-cased jr-id keep all 9 bis.org#jr decisions
  linked (applied jr100498 included).

NAV
- Search API 429s after ~9 requests and stays blocked for hours: discovery
  capped at 8 searches (4s pacing). Posting text now comes from NAV's official
  third-party feed (pam-stilling-feed, public rotating token), with a small,
  slow website-page fallback for ads the feed lacks (~2/3). Verified live on
  15 real ads; fallback path tested on fixtures.

Location filter
- EMEA leak fixed: a country allowlist without CH/DK plus a trailing region
  tag is no longer Europe-remote (27 rows). Swiss/Danish segments in
  multi-location strings are evaluated per segment. Regression over all
  2026-10-22 rows: only the fenced rows changed.

Reputation
- Every board now has a dated verdict; kununu added as a primary source next
  to Glassdoor (the one with more reviews decides; user tip). Second-source
  scores now count (a low one means avoid). AVOID: BIS (Glassdoor 2.9),
  BFH (kununu 3.3), Kdo Cy (federal administration kununu 3.4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
dennisthiessenandClaude Opus 5.5 committed 2026-10-22 10:44:45 +02:00
1 parent eee608a213
commit ff12f16545
3 files changed
+919 -76

No files matched your search

+2 -1
View File
@@ -184,7 +184,8 @@ _See `config.md` for user-specific corrections. Add verified errors here as you
| Date | Correction | Files fixed |
|------|-----------|-------------|
| 2026-10-21 | **Software design and architecture was under-recorded: the seventh canonical gap of the recurring shape, surfaced by the Google *Staff SWE, Shopping Graph* minimum *"3 years of experience with software design and architecture."*** Before this, the only design evidence was SW-7 (current, undated, 2026-10-03) and a *contribution* at Generali (GN-3), so the line read as unproven. User-confirmed: **software design and architecture were part of his work at Bosch (ingestion and inference scripts, 2020-2022) and at Swisscom (data products)**, and both count toward the year requirement; together they exceed three years. Recorded on BS-1, BS-2 and SW-7, with `designed` added to the BS-1/BS-2 allowed verbs. **Scope kept tight:** at Bosch, "designed" covers the ingestion scripts and the inference integration only, not the data services in general (the first patch over-extended it and was trimmed the same session). Design never means an architect title, design authority or shared-platform architecture. The Swisscom start date stays undated. **Lesson, the same as 10-08 and 10-14: when a JD has a years-of-X minimum that the canonical file states without a duration, ask for the duration before scoring.** | `claims.json` (BS-1, BS-2, SW-7); `config.md` (data-product design row); `knowledge_base/extractions/2026-10-21_software_design_architecture_clarification.md` |
| 2026-10-21 | **FIXED 2026-10-22 (Microsoft links); the EMEA leak below is still OPEN.** Fix: `fetch_pcsx` now emits `apply.careers.microsoft.com<positionUrl>?jobId=<displayJobId>`, a `?jobId=` pattern precedes the `/job/` one in `_JOB_ID_PATTERNS`, and `_HOST_ALIASES` maps the new host to the old so `_decision_key` is unchanged. Verified: all 7 logged Microsoft decisions still resolve (applied 200043897 included), and a new-form link opened the real posting with the job number on the page. Reports before 2026-10-22 still carry the dead form. Original finding: **every Microsoft link the scout emits is dead, in every report**, found in the 2026-10-20 triage. `fetch_pcsx` builds `jobs.careers.microsoft.com/global/en/job/<displayJobId>`, which now redirects to a generic `apply.careers.microsoft.com/careers?...&pid=1970393557041551` search page. Verified on 200060867 and on the **pending application 200043897** (Principal FDE). The application itself is unaffected; only the link is. The working form is `https://apply.careers.microsoft.com/careers/job/<pcsx id>`, the API's `positionUrl`. **Deliberately NOT patched yet:** 6 logged Microsoft decisions (including 200043897) are keyed on `displayJobId` through the `careers\.microsoft\.com /job/(\d+)` pattern. Swapping the URL would re-key them on the pcsx id and orphan them, the same failure as the 09-04 RUAG/BIS de-linking. **Safe fixes:** (a) keep `url` and add a working `apply_url` field to the row, or (b) emit the new URL with the display id carried in it, plus a host-normalised key pattern. Either way, verify all 6 rows still resolve through `find_decision_url` afterwards. Same triage, also OPEN: an **"EMEA" region suffix lets country-fenced roles through the location filter** (GitLab FR/DE/NL/ES/UK, Grafana ×5, Fivetran Serbia: 7 of 109 new rows), which breaks the docstring rule against multi-country allowlists that omit CH. | not yet fixed: `scout.py` (`fetch_pcsx`, location filter); evidence in `reports/2026-10-20.triage.md` |
| 2026-10-22 | **Scout findings fixed in one pass; the biggest one is that Apple had been blind to Switzerland for about two months.** (a) **Apple:** the `location=switzerland-CHE` slug stopped resolving and Apple silently applied a US location ("Chesterfield, Missouri"). Every run from at least 2026-08-31 to 2026-10-22 returned the same 21 US/intern cards and **0 Swiss roles**, which read as "Apple has nothing" rather than as a failure. The code is now `CHEC` (from `/api/v1/refData/postlocation`); the card is the result list item, so the location is readable; and there is pagination, a settle delay and per-element timeouts (one run took 876s, the next 25s). Result: **24 Swiss roles**, Zurich AIML/infra included. (b) **BIS:** bis.org went behind a Cloudflare challenge (403, even for a headless browser), so the board moved to its Workday tenant `bis.wd3.myworkdayjobs.com/External`, which also gives the real office. A host alias plus a lower-cased jr-id keep all 9 `bis.org#jr…` decisions linked (applied jr100498 included). (c) **NAV:** the search API 429s after ~9 requests and then for hours. Discovery now stays at 8 searches, and posting text comes from NAV's official third-party feed (`pam-stilling-feed`, public rotating token), which tolerated bursts. The feed lacks ~2/3 of ads, so a small, slow website-page fallback covers those, and verdicts are cached. (d) **EMEA leak fixed:** a country allowlist without CH/DK plus a trailing "EMEA" tag is no longer Europe-remote (27 rows removed). Swiss/Danish segments in multi-location strings are checked per segment, so "PL-Poland-Remote \| CH-Zurich-MSO" stays Swiss. A regression over all 2026-10-22 rows showed only the fenced rows changing. (e) **Reputation:** every board now has a dated verdict. Glassdoor and **kununu** are both primary sources, and whichever has more reviews decides (user tip: kununu is far better for Swiss employers). AVOID: **BIS** (Glassdoor 2.9/56, where an application is pending), **BFH** (kununu 3.3/132), **Kdo Cy** (federal-administration kununu 3.4/997, not unit-specific). **Lesson: a board that returns the same non-zero count with zero eligible rows for weeks is a silent failure, not an empty market. Check what filter the site actually applied.** | `scout.py` (Apple, BIS, NAV, location filter, reputation); `state/employer_reputation.json` |
| 2026-10-21 | **FIXED 2026-10-22 (Microsoft links; the EMEA leak below was fixed the same day, see the 2026-10-22 row).** Fix: `fetch_pcsx` now emits `apply.careers.microsoft.com<positionUrl>?jobId=<displayJobId>`, a `?jobId=` pattern precedes the `/job/` one in `_JOB_ID_PATTERNS`, and `_HOST_ALIASES` maps the new host to the old so `_decision_key` is unchanged. Verified: all 7 logged Microsoft decisions still resolve (applied 200043897 included), and a new-form link opened the real posting with the job number on the page. Reports before 2026-10-22 still carry the dead form. Original finding: **every Microsoft link the scout emits is dead, in every report**, found in the 2026-10-20 triage. `fetch_pcsx` builds `jobs.careers.microsoft.com/global/en/job/<displayJobId>`, which now redirects to a generic `apply.careers.microsoft.com/careers?...&pid=1970393557041551` search page. Verified on 200060867 and on the **pending application 200043897** (Principal FDE). The application itself is unaffected; only the link is. The working form is `https://apply.careers.microsoft.com/careers/job/<pcsx id>`, the API's `positionUrl`. **Deliberately NOT patched yet:** 6 logged Microsoft decisions (including 200043897) are keyed on `displayJobId` through the `careers\.microsoft\.com /job/(\d+)` pattern. Swapping the URL would re-key them on the pcsx id and orphan them, the same failure as the 09-04 RUAG/BIS de-linking. **Safe fixes:** (a) keep `url` and add a working `apply_url` field to the row, or (b) emit the new URL with the display id carried in it, plus a host-normalised key pattern. Either way, verify all 6 rows still resolve through `find_decision_url` afterwards. Same triage, also OPEN: an **"EMEA" region suffix lets country-fenced roles through the location filter** (GitLab FR/DE/NL/ES/UK, Grafana ×5, Fivetran Serbia: 7 of 109 new rows), which breaks the docstring rule against multi-country allowlists that omit CH. | not yet fixed: `scout.py` (`fetch_pcsx`, location filter); evidence in `reports/2026-10-20.triage.md` |
| 2026-10-15 | **Two scout adapters were reporting wrong data without failing; both were found by the 2026-10-14 triage.** (a) **BIS:** the RSS adapter stamped `default_location: Basel, Switzerland` on every vacancy, so **jr100491 (Hong Kong, BIS Asia office)** passed the CH filter and reached a JD read. The feed carries no location, but each vacancy page has a static *Geographical location* field. `fetch_rss` now reads it per item (`location_page_regex`), falls back to a *"based in ..."* sentence in the feed description, and only then uses `default_location`, printing a warning when it does. Verified: Basel reqs still pass, and jr100491 now reads *8 Finance Street, Central, Hong Kong* and is excluded. (b) **Confluent:** IBM closed the acquisition on 2026-03-17, and the Ashby board went from 17 jobs (10-08) to `{"jobs":[]}` (10-14). careers.confluent.io sits behind a Vercel bot checkpoint. The roles are now IBM requisitions, served by the open IBM careers search API (`www-api.ibm.com/search/api/v2`, POST, `size` capped at 100, pages with `from`). New `ibm` adapter; Confluent keeps its id, so seen-state and decisions still link. First run: 84 roles, 0 in CH/Europe-remote scope. **HashiCorp was dropped on 2026-06-06 for having "no clean public ATS API" after IBM absorbed it. That API exists, so HashiCorp can come back through the same adapter if wanted.** **Rule: when an adapter must assume a field, the assumption has to be visible. A silent default looks exactly like real data.** | `scout.py` (`fetch_rss`, `_rss_item_location`, `fetch_ibm`, BIS and Confluent config) |
| 2026-10-14 | **Spotfire was the sixth evidenced-but-unregistered skill, and junior mentoring was unrecorded - both surfaced by the BIS Basel jr100498 *Senior/Lead Data & Analytics Engineer* (MED IT) triage.** (a) **TIBCO Spotfire** sat in the BS-5 and BS-8 scopes (co-owned environment, technical Application Owner, C# extensions, TAF 2022, spatial wafer-data map charts) but not in the `skills` array, so the JD line *"using database reporting and business intelligence tools"* had no citable skill; registered `production-historical`, `allowed` on user instruction. It does not license Power BI, Tableau or Qlik. (b) The JD requires *"Experience providing general supervision and task-specific guidance to junior colleagues"*, and the only canonical evidence was BS-7 (students, one thesis as Zweitpruefer). User-confirmed: he **onboarded and mentored junior developers at Bosch and at Swisscom** - recorded as **BS-9** and **SW-10**, one per employer. The Swisscom phase (ART B2B or ART DPT) is not recorded, so write it in past tense. Neither is people management. (c) **SDMX: user-confirmed never used**; registered `forbidden` so the scanner catches it. It is an accountability line in the JD, not a qualification. (d) The same audit found **Teradata, Oracle Database and Hadoop/Impala** evidenced only in the SW-2/BS-2/BS-8 scopes and absent from the `skills` array, although every past skills line listed them; registered `production-historical` from those scopes (Oracle explicitly does not establish DBA work). **Lesson, same as 10-08: when a JD has a supervision or mentoring line, ask about junior colleagues before falling back on BS-7. Student supervision is weaker evidence and the stronger evidence was simply unrecorded.** | `claims.json` (BS-9, SW-10; skills TIBCO Spotfire, SDMX, Teradata, Oracle Database, Hadoop / Impala); `knowledge_base/extractions/2026-10-14_mentoring_spotfire_sdmx_clarification.md` |
| 2026-10-08 | **Three more canonical gaps of the recurring shape, surfaced by the Kongsberg Seatex *Lead Engineer, DevOps and Test Infrastructure* (Trondheim) package.** (a) **Jenkins and GitLab CI/CD** were evidenced in claim scopes (GN-1, FC-1, BS-6, SW-3) but absent from the `skills` array, so no skills line could cite them; registered as `production-historical`, with GitLab keeping SW-3's "used, not built" limit. (b) **Embedded coursework** was unrecorded: user-confirmed an Embedded Systems course (C/C++ and VHDL with an ARM platform) and Robotics during his studies, and **no embedded work since** - recorded as **ED-1**, coursework-only, degree attribution not yet given. (c) **Current unit testing** was unrecorded: he writes unit tests today and knows pytest - recorded as **SW-9**; whether pytest is the Swisscom framework is not confirmed. The JD's required line *"Experience working with embedded SW and Linux"* stays a **half Gap** (hard-gate FAIL); the user overrode it knowingly. **Lesson, again: for any DevOps/test JD ask about current testing practice and about academic hardware work before scoring - neither lives in `claims.json` by default.** | `claims.json` (ED-1, SW-9, 7 skills); `knowledge_base/extractions/2026-10-08_embedded_coursework_testing_clarification.md`; Kongsberg package |
+221 -41
View File
@@ -443,24 +443,37 @@ COMPANIES = [
# Nammo, SINTEF, Kitron, Andøya, the long tail). Keyword queries on a request budget;
# see fetch_nav for the rate-limit behaviour. Rows carry the real employer name.
("nav", "NAV register (Norway)", "nav", {
"queries": ["data engineer", "dataingeniør", "devops", "platform engineer",
"software engineer", "systemutvikler", "python", "machine learning",
"data platform", "cloud engineer", "site reliability", "backend",
"test automation", "utvikler"],
"search_budget": 14,
# Ordered by expected yield: only the first `search_budget` run. Measured 2026-10-22:
# 9 searches at 3s spacing triggered a 429 that lasted hours, so stay at 8 and pace 4s.
"queries": ["data engineer", "devops", "software engineer", "systemutvikler",
"python", "platform engineer", "dataingeniør", "utvikler",
"machine learning", "test automation", "site reliability", "backend"],
"search_budget": 8,
"pages_per_query": 1,
"detail_budget": 25,
"pace_s": 3.0,
"pace_s": 4.0,
# Text checks go to the official feed host (no 429 seen at 0.5s), so the budget can
# be generous; results are cached, so later runs only fetch new postings.
"detail_budget": 60,
"feed_pace_s": 0.5,
# Website-page fallback for ads the public feed lacks (10 of 15 on 2026-10-22).
# Same host as search, so few and slow; skipped entirely after a search 429.
"page_fallback_budget": 6,
"page_pace_s": 8.0,
"_location_policy": "norway_no_oslo",
}),
# International org — BIS (Basel), commutable from Bern, salary net of Swiss tax.
# Low-volume RSS feed; no title filter (Innovation Hub roles can be oddly titled).
("bis", "BIS (Basel)","rss", {
"url": "https://www.bis.org/doclist/vacancies.rss",
# Each vacancy page has a static "Geographical location" sidebar field. BIS also
# hires for Hong Kong, Mexico City and other offices; see _rss_item_location.
"location_page_regex": r"Geographical location</h6>\s*<div[^>]*>\s*<p>([^<]+)</p>",
"default_location": "Basel, Switzerland",
# BIS moved from the bis.org RSS feed to its Workday tenant on 2026-10-22: bis.org went
# behind a Cloudflare challenge that answers 403 even to a headless browser, while
# bis.wd3.myworkdayjobs.com/External serves the same vacancies WITH their office
# ("BIS Headquarters Basel" vs "BIS Representative Office for Asia and the Pacific"), so
# the per-vacancy location lookup the RSS adapter needed is no longer necessary.
# Decision keys stay on bis.org#jr…: see _HOST_ALIASES and the bis.org patterns.
("bis", "BIS (Basel)","workday", {
"host": "bis.wd3.myworkdayjobs.com",
"tenant": "bis",
"site": "External",
"nav_aliases": ["BIS"],
}),
# Dropped: Coinbase Ventures web3 talent network (Getro collection 1625) — 2026-08-18.
# Never carried Coinbase's own roles, only portfolio companies. Across ~3 months
@@ -510,11 +523,29 @@ COMPANIES = [
"_title_exclude": NOISE_TITLE_EXCLUDE,
}),
("apple", "Apple", "playwright", {
"url": "https://jobs.apple.com/en-us/search?location=switzerland-CHE",
# Location code is CHEC (postLocation-CHEC, from /api/v1/refData/postlocation). The old
# "switzerland-CHE" slug stopped resolving: Apple silently applied a US location
# ("Chesterfield, Missouri") instead, so every run from at least 2026-08-31 to
# 2026-10-22 returned the same 21 US/intern cards and 0 Swiss roles. Fixed 2026-10-22;
# the CHEC filter shows 25 Swiss roles, Zurich engineering included. If this board
# ever returns only non-Swiss cards again, re-check the code via that endpoint.
"url": "https://jobs.apple.com/en-us/search?location=switzerland-CHEC",
"wait_for": "a[href*='/en-us/details/']",
"card": "a[href*='/en-us/details/']",
"title_attr": "text",
"link_attr": "href",
"wait_timeout_ms": 45000,
"settle_ms": 6000,
"element_timeout_ms": 10000,
# 20 results per page, 1-indexed (25 Swiss roles on 2026-10-22 = 2 pages). Each Apple
# page is slow (an empty last page costs the full wait), so cap at 2 extra pages:
# up to 60 roles. Raise it if the Swiss board ever grows past that.
"page_param": "page",
"page_param_start": 2,
"max_pages": 2,
# The card is the result list item, not the title link: the link alone carries no
# location, so 26 of 28 Swiss rows read location-less and were dropped (2026-10-22).
# The item text reads "<title> <team> <date> Location Zurich Actions".
"card": "li.rc-accordion-item",
"title_sel": "a[href*='/en-us/details/']",
"link_sel": "a[href*='/en-us/details/']",
"url_prefix": "https://jobs.apple.com",
# NO default_location. Apple's own postLocation-CHE filter leaks global reqs whose
# postLocationId is postLocation-USA ("Various Locations within United States"), and
@@ -1746,6 +1777,10 @@ def fetch_playwright(args):
)
ctx.add_init_script(STEALTH_JS)
page = ctx.new_page()
# Per-element timeout for slow boards: Apple took 876s once and 25s the next time on the
# same day (2026-10-22); a stalled card read should fail fast, not hold the whole run.
if args.get("element_timeout_ms"):
page.set_default_timeout(args["element_timeout_ms"])
jobs = []
seen_ids = set()
@@ -1756,7 +1791,14 @@ def fetch_playwright(args):
wait_for = args.get("wait_for")
if wait_for:
try:
page.wait_for_selector(wait_for, timeout=15000)
# Per-board timeout: Apple's search page sometimes takes well over 15s to
# render its result cards, and a miss here returns 0 jobs (2026-10-22: 0 vs 21
# two days earlier, selectors unchanged).
page.wait_for_selector(wait_for, timeout=args.get("wait_timeout_ms", 15000))
# Boards that render results progressively need a pause after the FIRST card
# appears, or the scrape catches a partial list (Apple: 15 vs 24, 2026-10-22).
if args.get("settle_ms"):
page.wait_for_timeout(args["settle_ms"])
except Exception:
page.wait_for_timeout(4000)
# Scroll a few times to trigger any lazy-loaded results
@@ -1961,9 +2003,10 @@ def fetch_ibm(args):
# (user, 2026-10-22: EU/NATO citizen, expects to be clearable); only an explicit Norwegian-
# citizenship requirement is. Every verdict carries its evidence snippet so it can be checked.
_LANG_ENGLISH_OK = [re.compile(p, re.I) for p in (
r"norsk\s+og\s*/\s*eller\s+engelsk", r"engelsk\s+og\s*/\s*eller\s+norsk",
r"norsk\s+eller\s+engelsk", r"engelsk\s+eller\s+norsk",
r"skandinavisk\w*\s+(?:språk\s+)?(?:og\s*/\s*)?eller\s+engelsk",
# The optional hyphen covers compounds: "gode norsk- eller engelskkunnskaper".
r"norsk-?\s+og\s*/\s*eller\s+engelsk", r"engelsk-?\s+og\s*/\s*eller\s+norsk",
r"norsk-?\s+eller\s+engelsk", r"engelsk-?\s+eller\s+norsk",
r"skandinavisk\w*-?\s+(?:språk\s+)?(?:og\s*/\s*)?eller\s+engelsk",
r"engelsk\s+(?:og\s*/\s*)?eller\s+(?:et\s+)?skandinavisk",
r"english\s+(?:and\s*/\s*)?or\s+(?:a\s+)?(?:scandinavian|norwegian)",
r"(?:norwegian|scandinavian)\s+(?:and\s*/\s*)?or\s+english",
@@ -2068,6 +2111,50 @@ def classify_language_requirement(text, work_language_tags=None):
NAV_SEARCH_URL = "https://arbeidsplassen.nav.no/stillinger/api/search"
NAV_AD_URL = "https://arbeidsplassen.nav.no/stillinger/stilling/{uuid}"
NAV_DETAIL_CACHE = ROOT / "state" / "nav_detail_cache.json"
# Full ad text comes from NAV's OFFICIAL third-party feed, not the website. Measured
# 2026-10-22: the website search API 429s after ~9 requests at 3s spacing and the block then
# lasts hours, while the feed host served 12 requests at 0.5s without a 429. The feed entry
# endpoint takes the same uuid the search returns and returns clean ad HTML (no page chrome).
# Its bearer token is public but rotates (exp ~1 month), so it is fetched fresh each run.
# The feed itself cannot replace search for discovery: it pages chronologically from 2023 and
# ?last=true only shows changes from now on, not the ads already open.
NAV_FEED_TOKEN_URL = "https://pam-stilling-feed.nav.no/api/publicToken"
NAV_FEED_ENTRY_URL = "https://pam-stilling-feed.nav.no/api/v1/feedentry/{uuid}"
def _nav_feed_token():
req = urllib.request.Request(NAV_FEED_TOKEN_URL, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req, timeout=30, context=_ssl_context()) as resp:
body = resp.read().decode("utf-8", "replace")
match = re.search(r"eyJ[\w-]+\.[\w-]+\.[\w-]+", body)
if not match:
raise RuntimeError("NAV feed public token not found")
return match.group(0)
def _nav_ad_text(uuid, token, pace_s):
"""Full ad text for one posting via the official feed; raises _RateLimited on 429."""
time.sleep(pace_s)
req = urllib.request.Request(NAV_FEED_ENTRY_URL.format(uuid=uuid), headers={
"User-Agent": USER_AGENT, "Accept": "application/json",
"Authorization": f"Bearer {token}"})
try:
with urllib.request.urlopen(req, timeout=40, context=_ssl_context()) as resp:
entry = json.loads(resp.read().decode("utf-8", "replace"))
except urllib.error.HTTPError as e:
if e.code == 429:
raise _RateLimited(uuid) from e
raise
ad = entry.get("ad_content") or {}
return _html_to_text(f"{ad.get('title', '')} {ad.get('description') or ''}")
def _nav_page_text(job, pace_s):
"""Fallback: the arbeidsplassen ad page. Measured 2026-10-22 the public feed returned 404
for 10 of 15 relevant postings (ads it does not republish), so without this most Norway
ads would never get a language check. The page is on the rate-limited website host, so it
runs on its own small budget, slowly, and only when the search itself did not hit a 429."""
return _html_to_text(_nav_get(job["url"], pace_s))
class _RateLimited(RuntimeError):
@@ -2155,8 +2242,13 @@ def fetch_nav(args):
cache = json.loads(NAV_DETAIL_CACHE.read_text(encoding="utf-8"))
except (FileNotFoundError, ValueError):
cache = {}
fetched = pending = 0
blocked = any("rate limit" in w for w in warnings)
fetched = pending = page_fetched = 0
# The text checks use the official feed host, so a rate limit on the search host does not
# block them; only a 429 or a token failure on the feed itself does. Ads missing from the
# feed fall back to the website page, which shares the search host's rate limit.
blocked, token = False, None
page_ok = not any("rate limit" in w for w in warnings)
page_budget = args.get("page_fallback_budget", 6)
for job in jobs.values():
cached = cache.get(job["id"])
if cached:
@@ -2166,7 +2258,21 @@ def fetch_nav(args):
job["location"], policy="norway_no_oslo", country="NO")[0]
if relevant and not blocked and fetched < detail_budget:
try:
text = _html_to_text(_nav_get(job["url"], pace))
if token is None:
token = _nav_feed_token()
try:
text = _nav_ad_text(job["id"], token, args.get("feed_pace_s", 0.5))
except urllib.error.HTTPError as e:
if e.code != 404 or not page_ok or page_fetched >= page_budget:
raise
try:
text = _nav_page_text(job, args.get("page_pace_s", 8.0))
page_fetched += 1
except _RateLimited:
page_ok = False
warnings.append("NAV website rate limit during page fallback; ads the "
"public feed lacks stay provisional this run.")
raise urllib.error.URLError("page fallback rate-limited")
fetched += 1
verdict = classify_language_requirement(text, job["work_language"])
verdict["checked"] = datetime.now(timezone.utc).strftime("%Y-%m-%d")
@@ -2175,9 +2281,18 @@ def fetch_nav(args):
continue
except _RateLimited:
blocked = True
warnings.append("NAV rate limit hit during posting-text checks; remaining "
warnings.append("NAV feed rate limit hit during posting-text checks; remaining "
"postings use the work-language tag only.")
except RuntimeError as e:
blocked = True
warnings.append(f"NAV feed token unavailable ({e}); posting-text checks skipped "
f"this run.")
except urllib.error.HTTPError as e:
if e.code != 404:
warnings.append(f"NAV posting {job['id']}: text fetch failed ({e!r})")
# 404 = not in the public feed and no page budget left: counted as pending.
except (urllib.error.URLError, ValueError) as e:
if "page fallback rate-limited" not in str(e):
warnings.append(f"NAV posting {job['id']}: text fetch failed ({e!r})")
if relevant:
pending += 1
@@ -2186,7 +2301,9 @@ def fetch_nav(args):
job["lang_check"] = verdict
if pending:
warnings.append(f"{pending} relevant NAV posting(s) have no full-text language check yet "
f"(budget {detail_budget}/run); they are marked provisional.")
f"(checked {fetched} this run, {page_fetched} via the website fallback; "
f"budgets {detail_budget} feed / {page_budget} page); they are marked "
f"provisional and get checked on later runs as the cache fills.")
_atomic_write_json(NAV_DETAIL_CACHE, cache)
return list(jobs.values())
@@ -2320,8 +2437,38 @@ COUNTRY_NAMES = {
"FR": ("france", "frankreich", "fr"), "IT": ("italy", "italien", "it"),
"GB": ("united kingdom", "great britain", "uk", "gb"),
"US": ("united states", "usa", "us"), "NL": ("netherlands", "niederlande", "nl"),
# Added 2026-10-22 for the EMEA-leak fix: countries that appear in remote allowlists.
# Full names only - two-letter codes ("es", "se", "in", "be") would match ordinary words.
"IE": ("ireland", "republic of ireland"), "ES": ("spain",), "SE": ("sweden",),
"FI": ("finland",), "PL": ("poland",), "PT": ("portugal",), "BE": ("belgium",),
"RS": ("serbia",), "CZ": ("czech republic", "czechia"), "IL": ("israel",),
"CA": ("canada",), "IN": ("india",), "RO": ("romania",), "GR": ("greece",),
"HU": ("hungary",), "EE": ("estonia",), "LT": ("lithuania",), "LV": ("latvia",),
"UA": ("ukraine",), "TR": ("turkey", "türkiye"), "AE": ("united arab emirates",),
"SG": ("singapore",), "AU": ("australia",), "JP": ("japan",), "BR": ("brazil",),
}
# A real pan-European remote scope, as opposed to an ATS region tag appended to a country
# list ("Germany (Remote) EMEA", "Remote, France; ...; Remote, United Kingdom EMEA").
_GENUINE_EUROPE_SCOPE = re.compile(
r"remote[\s,(\-–:]+(?:in\s+)?(?:europe|emea|the\s+eu)\b|(?:europe|emea)[\s,\-–]+remote"
r"|anywhere\s+in\s+(?:europe|emea|the\s+world)|europe-wide|eu-wide|pan-european"
r"|worldwide|work\s+from\s+anywhere|remote\s*[-–]\s*global", re.I)
def _segment_names_place(loc_text, code, town_keywords):
"""True if one location segment names this country, or one of its towns without naming
any other country. Multi-location strings ("PL-Poland-Remote | CH-Zurich-MSO") otherwise
lose the Swiss/Danish segment as soon as another country is recognised, while a single
segment like "Baden, Germany" must not count as Swiss Baden."""
for seg in re.split(r"[|;\n]", (loc_text or "").lower()):
others = {c for c, names in COUNTRY_NAMES.items() if c != code and
any(_place_match(seg, [n]) for n in names if len(n) > 2)}
own = [n for n in COUNTRY_NAMES.get(code, ()) if len(n) > 2]
if _place_match(seg, own) or (_place_match(seg, town_keywords) and not others):
return True
return False
def _has_non_oslo_norway_place(loc_text):
"""True unless every location segment that can be placed is in the Oslo area.
@@ -2381,8 +2528,16 @@ def location_eligibility(loc_text, policy="default", country=None):
if re.search(r"(?:excluding|except|not in|außer|ausser|nicht in)\s+" + token, low) or re.search(token + r"\s+(?:excluded|not eligible|ausgeschlossen)", low):
excluded.add(code)
countries -= excluded
in_ch = "CH" in countries if explicit_country else _place_match(low, CH_LOCATION_KEYWORDS)
in_dk = "DK" in countries if explicit_country else _place_match(low, DK_LOCATION_KEYWORDS)
# With an adapter-supplied country, trust it. With countries parsed from free text, also
# check segment by segment so a Swiss/Danish segment survives beside another country.
if country:
in_ch, in_dk = "CH" in countries, "DK" in countries
elif explicit_country:
in_ch = "CH" in countries or _segment_names_place(loc_text, "CH", CH_LOCATION_KEYWORDS)
in_dk = "DK" in countries or _segment_names_place(loc_text, "DK", DK_LOCATION_KEYWORDS)
else:
in_ch = _place_match(low, CH_LOCATION_KEYWORDS)
in_dk = _place_match(low, DK_LOCATION_KEYWORDS)
in_no = "NO" in countries if explicit_country else _place_match(low, NO_LOCATION_KEYWORDS)
is_us_only = any(p in low for p in US_ONLY_PATTERNS) and not in_ch
has_remote = bool(re.search(r"\bremote\b", low)) or "work from anywhere" in low
@@ -2393,7 +2548,13 @@ def location_eligibility(loc_text, policy="default", country=None):
# (F12: "Europe" beside "London / Onsite" used to read as remote-eligible).
restricted = "CH" in excluded or bool(_ONSITE.search(low)) or (
bool(_COUNTRY_ONLY.search(low)) and not (in_ch or in_dk))
is_europe_or_global = (europe_scope or global_scope) and not is_us_only and not restricted
# EMEA leak (found 2026-10-20, fixed 2026-10-22): a country allowlist without CH/DK plus a
# trailing region tag is NOT pan-European scope. Seven such rows (GitLab FR/DE/NL/ES/UK,
# Grafana DE/IE/ES/SE/UK, Fivetran Serbia/Ireland) were passing as Europe-remote.
fenced = (explicit_country and not (in_ch or in_dk)
and not _GENUINE_EUROPE_SCOPE.search(low))
is_europe_or_global = ((europe_scope or global_scope) and not is_us_only and not restricted
and not fenced)
is_ch_remote = in_ch and has_remote
if policy == "norway_only":
@@ -2492,7 +2653,9 @@ def save_seen(seen):
# Board-specific req-ID patterns, tried in order against the URL path+query. Every key is
# scoped by host (see _decision_key), so an ID only ever has to be unique within one board
# and cross-board collisions are impossible by construction.
_HOST_ALIASES = {"apply.careers.microsoft.com": "jobs.careers.microsoft.com"}
_HOST_ALIASES = {"apply.careers.microsoft.com": "jobs.careers.microsoft.com",
# BIS Workday tenant (2026-10-22) keeps the bis.org#jr… decision keys.
"bis.wd3.myworkdayjobs.com": "bis.org"}
_JOB_ID_PATTERNS = [
# Amazon: the slug is truncated to a fixed width and that width has changed between runs,
@@ -2510,6 +2673,9 @@ _JOB_ID_PATTERNS = [
# orphaning every decision recorded under the old scheme — including the `applied` row for
# jr100429. The jr-id is stable across both. (Verified 2026-09-04.)
(r"bis\.org$", r"/(jr\d+)"),
# BIS Workday URLs end "..._JR100498" or "..._JR100499-1"; same jr-id, lower-cased in
# _decision_key so it matches the bis.org keys.
(r"bis\.org$", r"(?i)_(jr\d+)"),
(r"smartrecruiters\.com$", r"/(\d{6,})"),
(r"greenhouse\.io$", r"/jobs/(\d+)"),
(r"careers\.cisco\.com$", r"/job/(\d+)"),
@@ -2565,7 +2731,8 @@ def _decision_key(url):
continue
m = re.search(id_pat, target)
if m:
return f"{host}#{m.group(1)}"
ident = m.group(1).lower() if host == "bis.org" else m.group(1)
return f"{host}#{ident}"
# No known ID shape: fall back to the path with cosmetic variation stripped. The QUERY
# is kept (F13): dropping it made /vacancy?job=1 and /vacancy?job=2 the same key, so one
@@ -2763,15 +2930,28 @@ def reputation_verdict(entry, thresholds):
return "stale"
except ValueError:
pass
rating = (entry.get("glassdoor") or {}).get("rating")
reviews = (entry.get("glassdoor") or {}).get("reviews") or 0
if rating is None:
return "ok" if entry.get("secondary") else "unchecked"
# Glassdoor and kununu are both primary sources; the one with more reviews decides. For
# Swiss employers kununu is often far better populated (user tip, 2026-10-22: SBB 1,808
# kununu reviews vs no usable Glassdoor figure; RUAG 523 vs a thin Indeed 3.1).
primaries = [s for s in (entry.get("glassdoor"), entry.get("kununu"))
if s and s.get("rating") is not None]
primary = max(primaries, key=lambda s: s.get("reviews") or 0) if primaries else {}
rating = primary.get("rating")
reviews = primary.get("reviews") or 0
good_min = thresholds.get("good_min_rating", 3.8)
min_reviews = thresholds.get("good_min_reviews", 50)
ok_min = thresholds.get("ok_min_rating", 3.5)
# A second source counts only if it is solid: when it carries a score (Indeed, kununu),
# a score below the ok line makes the verdict avoid, not ok (RUAG: Indeed CH 3.1).
secondary_rating = entry.get("secondary_rating")
if rating is None:
if secondary_rating is not None:
return "ok" if secondary_rating >= ok_min else "avoid"
return "ok" if entry.get("secondary") else "unchecked"
if rating >= good_min and reviews >= min_reviews:
return "good"
if rating >= good_min and secondary_rating is not None and secondary_rating < ok_min:
return "avoid"
if rating >= good_min and entry.get("secondary"):
return "ok"
if rating >= ok_min:
@@ -2783,12 +2963,12 @@ def _reputation_line(rep):
verdict = rep.get("verdict", "unchecked").upper()
if verdict == "UNCHECKED":
return "UNCHECKED - look up before recommending (state/employer_reputation.json)"
gd = rep.get("glassdoor") or {}
bits = []
if gd.get("rating") is not None:
reviews = f", {gd['reviews']:,} reviews" if gd.get("reviews") else ""
scope = f", {gd['scope']}" if gd.get("scope") else ""
bits.append(f"Glassdoor {gd['rating']}/5{reviews}{scope}")
for label, src in (("Glassdoor", rep.get("glassdoor") or {}), ("kununu", rep.get("kununu") or {})):
if src.get("rating") is not None:
reviews = f", {src['reviews']:,} reviews" if src.get("reviews") else ""
scope = f", {src['scope']}" if src.get("scope") else ""
bits.append(f"{label} {src['rating']}/5{reviews}{scope}")
if rep.get("secondary"):
bits.append(rep["secondary"])
if rep.get("checked"):
@@ -3376,7 +3556,7 @@ def _main():
"source": r.get("source", ""),
"language": r.get("lang"),
"reputation": {k: r["reputation"].get(k) for k in
("verdict", "glassdoor", "secondary", "checked", "note")
("verdict", "glassdoor", "kununu", "secondary", "checked", "note")
if r["reputation"].get(k) is not None},
"decision": lookup_decision(dec_index, decisions, r["url"]),
"keyword_signal": {"score": r["score"], "pos": r["pos"], "neg": r["neg"]},
+696 -34
View File
@@ -4,7 +4,7 @@
"good_min_rating": 3.8,
"good_min_reviews": 50,
"ok_min_rating": 3.5,
"rule": "good: Glassdoor >= 3.8 with >= 50 reviews. ok: 3.5-3.79, or >= 3.8 on fewer than 50 reviews backed by a second source, or no Glassdoor page but a solid second source. avoid: below 3.5 unless the user overrides. excluded: user decision.",
"rule": "good: Glassdoor >= 3.8 with >= 50 reviews. ok: 3.5-3.79, or >= 3.8 on fewer than 50 reviews backed by a second source, or no Glassdoor page but a solid second source. avoid: below 3.5 unless the user overrides. excluded: user decision. A second source with a score (secondary_rating) must itself be >= 3.5 to back a thin or missing Glassdoor rating; below that the verdict is avoid. Glassdoor and kununu are both primary sources; whichever has more reviews decides (kununu is often far better populated for Swiss employers).",
"approved": "user, 2026-10-22"
},
"employers": {
@@ -15,86 +15,748 @@
"checked": "2026-10-22"
},
"Arm": {
"aliases": ["Arm Norway", "ARM Norway"],
"glassdoor": {"rating": 4.5, "reviews": 2633, "scope": "company-wide"},
"aliases": [
"Arm Norway",
"ARM Norway"
],
"glassdoor": {
"rating": 4.5,
"reviews": 2633,
"scope": "company-wide"
},
"note": "Trondheim-specific reviews are mixed: recent ones positive, a 2023 one criticised local middle management for engineers relocating from abroad.",
"sources": ["https://static.glassdoor.at/Reviews/Arm-Reviews-E7834_P800.htm", "https://static-pc.glassdoor.fr/Reviews/Employee-Review-Arm-E7834-RVW80607582.htm"],
"sources": [
"https://static.glassdoor.at/Reviews/Arm-Reviews-E7834_P800.htm",
"https://static-pc.glassdoor.fr/Reviews/Employee-Review-Arm-E7834-RVW80607582.htm"
],
"checked": "2026-10-22"
},
"Nordic Semiconductor": {
"aliases": ["Nordic Semi"],
"glassdoor": {"rating": 4.0, "reviews": 103, "recommend_pct": 83, "scope": "company-wide"},
"aliases": [
"Nordic Semi"
],
"glassdoor": {
"rating": 4.0,
"reviews": 103,
"recommend_pct": 83,
"scope": "company-wide"
},
"secondary": "TrueUp 82% recommend (100+ reviews)",
"sources": ["https://www.glassdoor.com/Overview/X-EI_IE681660.htm", "https://www.trueup.io/co/nordic-semi"],
"sources": [
"https://www.glassdoor.com/Overview/X-EI_IE681660.htm",
"https://www.trueup.io/co/nordic-semi"
],
"checked": "2026-10-22"
},
"Equinor": {
"aliases": [],
"glassdoor": {"rating": 4.3, "reviews": 769, "recommend_pct": 87, "scope": "company-wide"},
"glassdoor": {
"rating": 4.3,
"reviews": 769,
"recommend_pct": 87,
"scope": "company-wide"
},
"secondary": "Indeed 4.0 (299); Universum: top student employer in engineering/business/technology 2024",
"sources": ["https://www.glassdoor.co.uk/Reviews/Equinor-Reviews-E10408.htm", "https://uk.indeed.com/cmp/Equinor/reviews"],
"sources": [
"https://www.glassdoor.co.uk/Reviews/Equinor-Reviews-E10408.htm",
"https://uk.indeed.com/cmp/Equinor/reviews"
],
"checked": "2026-10-22"
},
"Kongsberg": {
"aliases": ["Kongsberg Gruppen", "Kongsberg Maritime", "Kongsberg Digital", "Kongsberg Defence", "Kongsberg Discovery", "Kongsberg Satellite Services", "KSAT", "Kongsberg Norcontrol", "Kongsberg Seatex"],
"not_aliases": ["Kongsberg Automotive"],
"glassdoor": {"rating": null, "reviews": null},
"aliases": [
"Kongsberg Gruppen",
"Kongsberg Maritime",
"Kongsberg Digital",
"Kongsberg Defence",
"Kongsberg Discovery",
"Kongsberg Satellite Services",
"KSAT",
"Kongsberg Norcontrol",
"Kongsberg Seatex"
],
"not_aliases": [
"Kongsberg Automotive"
],
"glassdoor": {
"rating": null,
"reviews": null
},
"secondary": "No Glassdoor page found; Indeed 3.8 (101 reviews, group); Universum 2025 professionals: 3rd in IT, 2nd in Engineering",
"note": "Ratings vary by division; Kongsberg Maritime JobStreet sample is small (7). Kongsberg Automotive is an unrelated company and is NOT covered by this entry.",
"sources": ["https://uk.indeed.com/cmp/Kongsberg-1", "https://www.kongsberg.com/news/stories/2025/11/Professionals-place-KONGSBERG-among-Norways-top-employers-in-Universum/"],
"checked": "2026-10-22"
"sources": [
"https://uk.indeed.com/cmp/Kongsberg-1",
"https://www.kongsberg.com/news/stories/2025/11/Professionals-place-KONGSBERG-among-Norways-top-employers-in-Universum/"
],
"checked": "2026-10-22",
"secondary_rating": 3.8
},
"DNV": {
"aliases": ["DNV AS"],
"glassdoor": {"rating": 4.2, "reviews": 94, "recommend_pct": 82, "scope": "company-wide (CA/NZ snapshot; UK page 3.9 on 47)"},
"aliases": [
"DNV AS"
],
"glassdoor": {
"rating": 4.2,
"reviews": 94,
"recommend_pct": 82,
"scope": "company-wide (CA/NZ snapshot; UK page 3.9 on 47)"
},
"secondary": "Indeed 3.8 (444 reviews, Feb 2026)",
"note": "Praised for flexibility and people; common complaints are pay and slow progression.",
"sources": ["https://www.glassdoor.ca/Reviews/DNV-Reviews-E6648554.htm", "https://uk.indeed.com/cmp/Dnv/reviews"],
"sources": [
"https://www.glassdoor.ca/Reviews/DNV-Reviews-E6648554.htm",
"https://uk.indeed.com/cmp/Dnv/reviews"
],
"checked": "2026-10-22"
},
"SINTEF": {
"aliases": [],
"glassdoor": {"rating": 4.0, "reviews": 62, "recommend_pct": 81, "scope": "company-wide"},
"glassdoor": {
"rating": 4.0,
"reviews": 62,
"recommend_pct": 81,
"scope": "company-wide"
},
"note": "Autonomy and good people; salary on the lower end, some bureaucracy for project leads.",
"sources": ["https://www.glassdoor.co.in/Reviews/SINTEF-Reviews-E458950.htm"],
"sources": [
"https://www.glassdoor.co.in/Reviews/SINTEF-Reviews-E458950.htm"
],
"checked": "2026-10-22"
},
"Telenor": {
"aliases": ["Telenor Norge", "Telenor ASA"],
"glassdoor": {"rating": 3.8, "reviews": 1305, "recommend_pct": 65, "scope": "company-wide, not Norway-specific"},
"aliases": [
"Telenor Norge",
"Telenor ASA"
],
"glassdoor": {
"rating": 3.8,
"reviews": 1305,
"recommend_pct": 65,
"scope": "company-wide, not Norway-specific"
},
"secondary": "Indeed 4.1 (311)",
"note": "Exactly at the threshold. Good work-life balance; complaints about limited growth and frequently changing strategy.",
"sources": ["https://www.glassdoor.com/Reviews/Telenor-Reviews-E11282.htm"],
"sources": [
"https://www.glassdoor.com/Reviews/Telenor-Reviews-E11282.htm"
],
"checked": "2026-10-22"
},
"Microsoft": {
"aliases": ["Microsoft Norge", "Microsoft Development Center Norway"],
"glassdoor": {"rating": 4.0, "reviews": 54474, "recommend_pct": 76, "scope": "company-wide"},
"sources": ["https://static-pc.glassdoor.fr/Reviews/Microsoft-Reviews-E1651.htm"],
"aliases": [
"Microsoft Norge",
"Microsoft Development Center Norway"
],
"glassdoor": {
"rating": 4.0,
"reviews": 54474,
"recommend_pct": 76,
"scope": "company-wide"
},
"sources": [
"https://static-pc.glassdoor.fr/Reviews/Microsoft-Reviews-E1651.htm"
],
"checked": "2026-10-22"
},
"NATO": {
"aliases": ["NATO JWC", "Joint Warfare Centre"],
"glassdoor": {"rating": 4.0, "reviews": 240, "recommend_pct": 93, "scope": "all NATO bodies"},
"aliases": [
"NATO JWC",
"Joint Warfare Centre"
],
"glassdoor": {
"rating": 4.0,
"reviews": 240,
"recommend_pct": 93,
"scope": "all NATO bodies"
},
"note": "Pay, benefits, international setting; heavy workload, bureaucracy, rigid military structure. Reviews do not separate civilian staff.",
"sources": ["https://www.glassdoor.com/Reviews/NATO-Reviews-E22846.htm"],
"sources": [
"https://www.glassdoor.com/Reviews/NATO-Reviews-E22846.htm"
],
"checked": "2026-10-22"
},
"Nammo": {
"aliases": [],
"glassdoor": {"rating": 3.9, "reviews": 21, "recommend_pct": 56, "scope": "company-wide"},
"glassdoor": {
"rating": 3.9,
"reviews": 21,
"recommend_pct": 56,
"scope": "company-wide"
},
"secondary": "Indeed 3.2 (87 reviews)",
"verdict_override": "avoid",
"note": "Thin and mixed: Glassdoor 3.9 on only 21 reviews with 56% recommending; the larger Indeed sample is 3.2, below the 3.5 line. A 2026 review alleges production pressure compromising safety. User may override.",
"sources": ["https://www.glassdoor.co.nz/Reviews/Nammo-Reviews-E4444878.htm", "https://www.indeed.com/cmp/Nammo"],
"checked": "2026-10-22"
"sources": [
"https://www.glassdoor.co.nz/Reviews/Nammo-Reviews-E4444878.htm",
"https://www.indeed.com/cmp/Nammo"
],
"checked": "2026-10-22",
"secondary_rating": 3.2
},
"Kitron": {
"aliases": [],
"glassdoor": {"rating": 3.1, "reviews": 15, "recommend_pct": 34, "scope": "company-wide"},
"glassdoor": {
"rating": 3.1,
"reviews": 15,
"recommend_pct": 34,
"scope": "company-wide"
},
"note": "Below threshold on a thin sample: limited growth, pay, unclear strategy.",
"sources": ["https://www.glassdoor.co.uk/Reviews/Kitron-Reviews-E311133.htm"],
"sources": [
"https://www.glassdoor.co.uk/Reviews/Kitron-Reviews-E311133.htm"
],
"checked": "2026-10-22"
},
"Google": {
"glassdoor": {
"rating": 4.4,
"reviews": 48616,
"scope": "company-wide",
"recommend_pct": 87
},
"sources": [
"https://static.glassdoor.ca/Reviews/Google-Reviews-E9079.htm"
],
"aliases": [],
"checked": "2026-10-22"
},
"NVIDIA": {
"glassdoor": {
"rating": 4.4,
"reviews": 5486,
"scope": "company-wide",
"recommend_pct": 91
},
"sources": [
"https://static.glassdoor.at/Reviews/NVIDIA-Reviews-E7633_P92.htm"
],
"aliases": [],
"checked": "2026-10-22"
},
"Roche": {
"glassdoor": {
"rating": 4.1,
"reviews": 6459,
"scope": "company-wide",
"recommend_pct": 82
},
"note": "Recurring: job-security worries during transformation, slow progression; experience varies by team.",
"sources": [
"https://static.glassdoor.fr/Reviews/Roche-Reviews-E3480_P90.htm"
],
"aliases": [],
"checked": "2026-10-22"
},
"Novartis": {
"glassdoor": {
"rating": 4.0,
"reviews": 9050,
"scope": "company-wide",
"recommend_pct": 80
},
"note": "Recurring: slow advancement, restructuring.",
"sources": [
"https://static.glassdoor.ch/Reviews/Novartis-Reviews-E6667_P33.htm"
],
"aliases": [],
"checked": "2026-10-22"
},
"Apple": {
"glassdoor": {
"rating": 4.1,
"reviews": 43100,
"scope": "company-wide",
"recommend_pct": 79
},
"sources": [
"https://static.glassdoor.de/Reviews/Apple-Reviews-E1138_P159.htm"
],
"aliases": [],
"checked": "2026-10-22"
},
"OpenAI": {
"glassdoor": {
"rating": 4.1,
"reviews": 100,
"scope": "company-wide",
"recommend_pct": 74
},
"note": "High pace and workload; culture said to have become more top-down.",
"sources": [
"https://www.glassdoor.com.au/Reviews/OpenAI-Reviews-E2210885.htm"
],
"aliases": [],
"checked": "2026-10-22"
},
"Anthropic": {
"glassdoor": {
"rating": 4.2,
"reviews": 30,
"scope": "company-wide",
"recommend_pct": 86
},
"secondary": "JobsByCulture aggregate 4.4 (222 Glassdoor reviews, Mar 2026)",
"note": "Glassdoor's own page is thin (27-34 reviews); main complaint is workload.",
"sources": [
"https://www.glassdoor.co.in/Reviews/Anthropic-Reviews-E8109027.htm",
"https://makerstations.io/anthropic-employee-statistics"
],
"aliases": [],
"checked": "2026-10-22"
},
"Kraken": {
"aliases": [
"Payward"
],
"glassdoor": {
"rating": 4.4,
"reviews": 216,
"scope": "company-wide",
"recommend_pct": 91
},
"note": "No HR function by design; pay not top of market.",
"sources": [
"https://static.glassdoor.es/Reviews/Kraken-Reviews-E8860582.htm"
],
"checked": "2026-10-22"
},
"Databricks": {
"glassdoor": {
"rating": 4.0,
"reviews": 1620,
"scope": "company-wide",
"recommend_pct": 76
},
"sources": [
"https://static.glassdoor.ch/Reviews/Databricks-Reviews-E954734_P7.htm"
],
"aliases": [],
"checked": "2026-10-22"
},
"Snowflake": {
"glassdoor": {
"rating": 3.7,
"reviews": 1087,
"scope": "company-wide",
"recommend_pct": 66
},
"note": "Below the good line: KPI pressure, more corporate/political.",
"sources": [
"https://static.glassdoor.de/Reviews/Snowflake-Reviews-E928471.htm"
],
"aliases": [],
"checked": "2026-10-22"
},
"Palantir": {
"glassdoor": {
"rating": 3.7,
"reviews": 886,
"scope": "company-wide",
"recommend_pct": 65
},
"note": "Work-life balance weak (aggregator 2.8); long hours.",
"sources": [
"https://static.glassdoor.co.uk/Reviews/Palantir-Technologies-Reviews-E236375.htm"
],
"aliases": [],
"checked": "2026-10-22"
},
"Amazon": {
"aliases": [
"AWS",
"Amazon Web Services",
"Amazon / AWS"
],
"glassdoor": {
"rating": 3.6,
"reviews": 13935,
"scope": "AWS",
"recommend_pct": 62
},
"note": "Work-life balance 3.0.",
"sources": [
"https://static.glassdoor.at/Reviews/Amazon-Web-Services-Reviews-E7470741_P74.htm"
],
"checked": "2026-10-22"
},
"Meta": {
"glassdoor": {
"rating": 3.6,
"reviews": 17952,
"scope": "company-wide",
"recommend_pct": 55
},
"sources": [
"https://www.glassdoor.co.uk/metacareers"
],
"aliases": [],
"checked": "2026-10-22"
},
"GitLab": {
"glassdoor": {
"rating": 3.6,
"reviews": 735,
"scope": "company-wide",
"recommend_pct": 58
},
"note": "Mixed (3.3-3.6 across snapshots): leadership and culture shifts.",
"sources": [
"https://www.glassdoor.com/Reviews/GitLab-Reviews-E1296544.htm"
],
"aliases": [],
"checked": "2026-10-22"
},
"Grafana Labs": {
"aliases": [
"Grafana"
],
"glassdoor": {
"rating": 4.1,
"reviews": 215,
"scope": "company-wide",
"recommend_pct": 79
},
"note": "Frequent leadership changes. Geo-fenced pay (see scout memory).",
"sources": [
"https://www.glassdoor.com/Reviews/Grafana-Labs-Reviews-E2300269.htm"
],
"checked": "2026-10-22"
},
"Datadog": {
"glassdoor": {
"rating": 4.0,
"reviews": 1630,
"scope": "company-wide",
"recommend_pct": 75
},
"note": "3 office days/week, no remote.",
"sources": [
"https://static.glassdoor.it/Reviews/Datadog-Reviews-E762009.htm"
],
"aliases": [],
"checked": "2026-10-22"
},
"Elastic": {
"glassdoor": {
"rating": 3.7,
"reviews": 880,
"scope": "company-wide",
"recommend_pct": 68
},
"note": "Snapshots range 3.6-3.9; layoffs and reorgs.",
"sources": [
"https://www.glassdoor.co.uk/Reviews/Elastic-Reviews-E751551.htm"
],
"aliases": [],
"checked": "2026-10-22"
},
"Fivetran": {
"glassdoor": {
"rating": 3.6,
"reviews": 440,
"scope": "company-wide",
"recommend_pct": 66
},
"secondary": "Blind 3.5 (103)",
"note": "Leadership and layoffs criticised.",
"sources": [
"https://www.glassdoor.com.au/Reviews/Fivetran-Reviews-E1415980.htm"
],
"aliases": [],
"checked": "2026-10-22"
},
"Cisco": {
"aliases": [
"Isovalent",
"Splunk"
],
"glassdoor": {
"rating": 4.1,
"reviews": 33605,
"scope": "company-wide",
"recommend_pct": 82
},
"note": "Recurring layoffs.",
"sources": [
"https://static.glassdoor.com.mx/Reviews/Cisco-Reviews-E1425_P51.htm"
],
"checked": "2026-10-22"
},
"IBM": {
"aliases": [
"Confluent",
"Confluent (IBM)",
"HashiCorp"
],
"glassdoor": {
"rating": 3.9,
"reviews": 107347,
"scope": "company-wide",
"recommend_pct": 78
},
"note": "IBM rating; Confluent has been IBM since 2026-03-17.",
"sources": [
"https://static.glassdoor.it/Reviews/IBM-Reviews-E354_P4.htm"
],
"checked": "2026-10-22"
},
"Axpo": {
"glassdoor": {
"rating": 3.9,
"reviews": 91,
"scope": "company-wide",
"recommend_pct": 82
},
"sources": [
"https://www.glassdoor.com/Reviews/Axpo-Reviews-E755157.htm"
],
"aliases": [],
"checked": "2026-10-22"
},
"CERN": {
"glassdoor": {
"rating": 4.3,
"reviews": 854,
"scope": "company-wide",
"recommend_pct": 91
},
"note": "Pay low for Switzerland; short-term contracts.",
"sources": [
"https://www.glassdoor.com/Reviews/CERN-Reviews-E150483.htm"
],
"aliases": [],
"checked": "2026-10-22"
},
"MET Group": {
"aliases": [
"METGroup"
],
"glassdoor": {
"rating": 3.7,
"reviews": 34,
"scope": "company-wide, 2024 snapshot",
"recommend_pct": 70
},
"note": "Thin and dated (24-34 reviews, 2024).",
"sources": [
"https://www.glassdoor.ie/Reviews/Met-Group-Reviews-E734317.htm"
],
"checked": "2026-10-22"
},
"BIS": {
"aliases": [
"Bank for International Settlements",
"BIS (Basel)"
],
"glassdoor": {
"rating": 2.9,
"reviews": 56,
"scope": "company-wide",
"recommend_pct": 42
},
"note": "BELOW threshold: 42% recommend, 33% CEO approval (page may list an outdated CEO). Thin but 56 reviews. NB: an application (jr100498) is pending there.",
"sources": [
"https://www.glassdoor.com/Overview/X-EI_IE10670.htm"
],
"checked": "2026-10-22"
},
"RUAG": {
"aliases": [
"RUAG (Thun/Bern)"
],
"not_aliases": [
"RUAG International"
],
"glassdoor": {
"rating": null,
"reviews": null
},
"secondary": "Indeed CH 3.1 (sample size not shown)",
"note": "kununu group 3.5 on 523 reviews (73% recommend, last 2 years) replaces the thin Indeed 3.1. Subsidiaries vary: RUAG Aviation 3.1 (26), RUAG Ammotec 3.0 (17); RUAG International (2.8) is a different entity. Industry average 3.4.",
"sources": [
"https://ch.indeed.com/cmp/Ruag/reviews",
"https://www.kununu.com/ch/ruag"
],
"checked": "2026-10-22",
"kununu": {
"rating": 3.5,
"reviews": 523,
"recommend_pct": 73,
"scope": "RUAG Schweiz group profile"
}
},
"SBB": {
"glassdoor": {
"rating": null,
"reviews": null
},
"secondary": "Indeed CH 4.2 (46); kununu 3.46 (289) in 2015, dated",
"sources": [
"https://ch.indeed.com/cmp/Sbb-Ag/reviews",
"https://www.netzwoche.ch/news/2015-02-05/top-10-der-schweizer-arbeitgeber-ausgezeichnet",
"https://www.kununu.com/ch/sbbcffffs/kommentare"
],
"aliases": [],
"checked": "2026-10-22",
"kununu": {
"rating": 3.8,
"reviews": 1808,
"scope": "SBB CFF FFS"
},
"note": "Praised: atmosphere, pay and benefits, home office; criticised: leadership and communication. An older SBB Informatik review is notably critical."
},
"Swissgrid": {
"aliases": [
"Swissgrid (Aarau)"
],
"glassdoor": {
"rating": 4.2,
"reviews": 6,
"scope": "6 visible of 45",
"recommend_pct": 91
},
"secondary": "jobs.ch 15 reviews, mostly positive",
"note": "Snapshots range 3.5-3.9 (135-209 reviews). Pension fund, pay, varied work; leadership (3.5) and workload criticised; many external contractors.",
"sources": [
"https://www.glassdoor.ca/Reviews/Swissgrid-Reviews-E673595.htm",
"https://www.jobs.ch/en/companies/7621-swissgrid-ag/reviews/",
"https://www.kununu.com/ch/swissgrid"
],
"checked": "2026-10-22",
"kununu": {
"rating": 3.6,
"reviews": 146
}
},
"PostFinance": {
"aliases": [
"PostFinance (Bern)"
],
"glassdoor": {
"rating": 4.1,
"reviews": 16,
"scope": "company-wide",
"recommend_pct": 79
},
"secondary": "Indeed CH 3.6 (25)",
"note": "Colleagues 4.1, communication 3.3; promotion criticised; an Oct 2026 comment reports a recent decline in atmosphere and work-life balance.",
"sources": [
"https://www.glassdoor.com/Reviews/PostFinance-Reviews-E602813.htm",
"https://ch.indeed.com/cmp/Postfinance-Ag/reviews",
"https://www.kununu.com/ch/postfinance6/kommentare"
],
"checked": "2026-10-22",
"kununu": {
"rating": 3.8,
"reviews": 497,
"recommend_pct": 76
}
},
"BFH": {
"aliases": [
"Berner Fachhochschule",
"BFH (Bern)"
],
"glassdoor": {
"rating": null,
"reviews": null
},
"secondary": "Indeed CH 3.5; Glassdoor career-opportunities sub-score 3.9 (64); Swiss Arbeitgeber Award 2022 top-3 large employer",
"sources": [
"https://ch.indeed.com/cmp/Berner-Fachhochschule",
"https://www.plattformj.ch/artikel/204982/",
"https://www.kununu.com/ch/berner-fachhochschule"
],
"checked": "2026-10-22",
"kununu": {
"rating": 3.3,
"reviews": 132,
"recommend_pct": 52
},
"note": "Below the education-sector average (3.6): communication 2.6, leadership 2.8; interesting work 3.8."
},
"BKW": {
"aliases": [
"BKW (Bern)",
"BKW Energie"
],
"not_aliases": [
"BKW Management"
],
"glassdoor": {
"rating": null,
"reviews": null
},
"note": "Interesting work and conditions; communication (3.2) and leadership criticised.",
"sources": [
"https://www.glassdoor.com/Reviews/BKW-Reviews-E40434.htm",
"https://www.kununu.com/ch/bkwag"
],
"checked": "2026-10-22",
"kununu": {
"rating": 3.7,
"reviews": 345,
"recommend_pct": 70,
"scope": "BKW AG"
}
},
"Schweizer Armee": {
"aliases": [
"Kdo Cy",
"Kommando Cyber",
"Schweizer Armee - Kdo Cy",
"Bundesverwaltung"
],
"glassdoor": {
"rating": null,
"reviews": null
},
"note": "Federal administration profile (no Kdo-Cy-specific profile): varied work, pay and benefits praised; career progression, communication and leadership criticised; slow hiring processes. Below the public-administration average (3.5).",
"sources": [
"https://www.jobs.ch/en/companies/70645-bundesverwaltung/reviews/",
"https://www.kununu.com/ch/vbs-schweizer-armee"
],
"checked": "2026-10-22",
"kununu": {
"rating": 3.4,
"reviews": 997,
"recommend_pct": 63,
"scope": "Bundesverwaltung / VBS profile"
}
},
"QuantCo": {
"glassdoor": {
"rating": null,
"reviews": null
},
"note": "Thin (18 reviews, German entity); pay and benefits 5.0. Not to be confused with Quant Qualitaetssicherung.",
"aliases": [],
"checked": "2026-10-22",
"kununu": {
"rating": 4.3,
"reviews": 18,
"recommend_pct": 94,
"scope": "QuantCo Germany (Rodgau)"
},
"sources": [
"https://www.kununu.com/de/quantco6"
]
},
"Bitcoin Suisse": {
"glassdoor": {
"rating": null,
"reviews": null
},
"note": "Thin (14-18 reviews); leadership said to have worsened after a management change.",
"sources": [
"https://trueup.io/co/bitcoin-suisse",
"https://www.kununu.com/ch/bitcoin-suisse1"
],
"aliases": [],
"checked": "2026-10-22",
"kununu": {
"rating": 3.8,
"reviews": 18,
"recommend_pct": 77
}
}
}
}