From ff12f16545ee37da68090c7e2c1f74e7fa30c1b6 Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Thu, 22 Oct 2026 10:44:45 +0200 Subject: [PATCH] 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 --- CLAUDE.md | 3 +- job_scout/scout.py | 264 ++++++-- job_scout/state/employer_reputation.json | 730 +++++++++++++++++++++-- 3 files changed, 920 insertions(+), 77 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index beb0a6e..39c1215 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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?jobId=`, 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/`, 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/`, 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?jobId=`, 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/`, 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/`, 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 | diff --git a/job_scout/scout.py b/job_scout/scout.py index 5c42725..76a7d07 100644 --- a/job_scout/scout.py +++ b/job_scout/scout.py @@ -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\s*]*>\s*

([^<]+)

", - "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 " <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,10 +2281,19 @@ 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: - warnings.append(f"NAV posting {job['id']}: text fetch failed ({e!r})") + 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 verdict = classify_language_requirement(job["description"], job["work_language"]) @@ -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"]}, diff --git a/job_scout/state/employer_reputation.json b/job_scout/state/employer_reputation.json index ecb42da..2d3c0c2 100644 --- a/job_scout/state/employer_reputation.json +++ b/job_scout/state/employer_reputation.json @@ -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 + } } } }