feat(scout): automate Kdo Cy via Prospective API; close SBB and Kdo Cy

Scout
- New `prospective` adapter for ohws.prospective.ch, wired up as `kdocy`
  (jobs.admin.ch, filtered to Kommando Cyber). jobs.admin.ch is an S3-hosted
  SPA that returns AccessDenied to any direct fetch, headless included, so the
  site cannot be scraped -- but the SPA reads a public, unauthenticated API.
  All 463 federal roles in ~2.4s, against RUAG's 35s Playwright scrape.
- This board is richer than any other source: Lohnklasse, application deadline,
  a named contact per posting, and the full Aufgaben/Anforderungen body, so
  scoring runs against the JD text rather than the title.
- Thread `deadline` through normalization, the markdown report and the JSON
  dump. A board with hard deadlines needs them on the row.
- Match `verwaltungseinheit_*` by prefix: the sub-unit key is department
  specific (VBS 1083398, EFD 1083364) and hardcoding one breaks reuse.
- MANUAL_CHECK entry for the Bundeswehr Reserve portal. The data is reachable
  (SAP E-Recruiting OData, SearchCategory 0022) but the portal actively blocks
  automation, and getting through needs UA spoofing plus masking
  navigator.webdriver. Not wired up by choice -- the operator-sanctioned
  Suchauftrag/job alert is the better channel. Reasoning is in the comment.

Applications
- SBB Data Engineer 103755: interviewed 2026-09-09, WITHDRAWN by the candidate
  2026-09-11. Net downgrade on four axes (41h vs 40h, 25+Brueckentage vs 28+5,
  3 vs 2 office days, and Anforderungsniveau K tops out ~145k against ~135k
  inside a band running to ~160k -- he would enter K at its ceiling merely to
  break even). The cohort's only interview, and it ended as his no, not theirs.
- Kdo Cy DevOps Engineer III: REJECTED 2026-09-10, 14 days, no interview.
  Form letter with no diagnostic content. Identical on every recorded
  dimension to SBB (core / 79 / gate pass / DQ 92 / weak channel) and the two
  went opposite ways -- recorded as worth watching, not as evidence for the
  channel thesis.
- Citadel row cleaned up: it claimed the cohort and decision-log entries were
  deliberately unwritten while its own status line read SUBMITTED. Both exist.
  The next-step cell still said "ask BEFORE building the package" two weeks
  after submission.

Decision log
- 69 RUAG rows -> 3 undecided. 63 title/family rule-outs, each noting that it
  is not a JD read, plus three that are: Test Manager C5I, System Engineer C5I,
  ICT Solution Architect. Guarded against the shared F/A-18 slug so one write
  could not silently swallow the other.

Note: the K->L/Kader rule is SBB-specific and was deliberately narrowed --
Anforderungsniveau is SBB's own system and does not map onto jobs.admin.ch
(Lohnklassen), RUAG or PostFinance. Thun-area employers sit outside the Bern
salary logic entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qej5SBvhcbGXHwLqNWiskM
This commit is contained in:
dennisthiessenandClaude Opus 5 committed 2026-09-11 17:30:46 +02:00
1 parent a0ec2fedb5
commit 0d999ab21b
8 files changed
+1084 -23

No files matched your search

+139 -1
View File
@@ -32,6 +32,7 @@ resist scraping even headless and stay in MANUAL_CHECK (surfaced as a report che
See the adapter-coverage notes at the bottom for the current automated/manual split.
"""
import html
import json
import os
import re
@@ -464,6 +465,35 @@ COMPANIES = [
# Swissgrid (Aarau): Magnolia CMS JSON endpoint (verified). placeOfWork is a bare city
# (Aarau/Prilly/...), so loc_suffix tags it Switzerland for the CH filter. No title filter
# (small board ~13 roles; lets Data Scientist / Applied-ML roles surface).
# Swiss federal job portal (jobs.admin.ch) via the Prospective API, filtered to
# Kommando Cyber. Added 2026-09-10 on user request: the Kdo Cy DevOps Engineer III req
# was found and applied to MANUALLY (rejected 2026-09-10) because nothing here was
# automated, and 15 Kdo Cy roles were open at the time this was wired up.
#
# `units` matches substrings against the sub-unit name. Other VBS units in the same
# defence-tech lane, one line away if wanted (counts as of 2026-09-10):
# "armasuisse" (5) · "Bundesamt für Cybersicherheit" (1) · "Nachrichtendienst" (10)
# "Kommando Ausbildung" (19) · "Kommando Operationen" (50) · "Armeestab" (6)
# Drop `units` entirely to scan all 463 federal roles and let the scorer filter.
#
# Pay: rows carry a `lohnklasse`. The official EPA table (maxima, 2026) is recorded in
# memory `reference_bund_lohnklassen` — LK 18 120'923 · 22 142'832 · 24 158'954 · 26 175'617,
# with BPV Anhang 1 splitting Standardplan (LK 1-23) from Kaderplan (LK 24-38). It is
# CALIBRATION ONLY: the user ruled 2026-09-11 that Lohnklasse must NOT be a screening gate,
# since Ortszuschlag, Familienzulagen and benefits make LK 24 and below workable.
# _score_floor: Kdo Cy titles are German administrative ("ICT-System Engineer",
# "ICT-Architekt/-in III") and score unevenly under English keywords — same reason
# NATO and SBB carry a floor. Keep pre-filtered roles from this unit visible.
("kdocy", "Schweizer Armee - Kdo Cy", "prospective", {
"medium_id": "1000624",
"units": ["Kommando Cyber"],
"lang": "de",
"_score_floor": 2,
# The floor lifts every German-titled row to 2, which would also float the
# apprenticeship intake. These terms are unambiguous on this board.
"_title_exclude": ["lernende", "lehrgang", "praktikant", "praktikum", "ausbildung zum",
"ausbildung zur", "sportlehre", "musiklehre"],
}),
("swissgrid", "Swissgrid (Aarau)", "json", {
"url": "https://www.swissgrid.ch/.rest/cloud/component-data?path=%2Fswissgrid%2Fen%2Fhome%2Fcareer%2Fjobs%2Fmain%2Fjoblist_transferred_11",
"jobs_key": "jobs",
@@ -565,6 +595,23 @@ MANUAL_CHECK = [
# headless browser, so there is no automated path. Manual only.
("NATO NCIA", "Cloudflare-blocked to headless browsers. NCIA is the tech arm and is separate from the JWC Stavanger reqs the `nato` adapter returns — check it manually for AI/data roles.",
"https://www.ncia.nato.int/careers.html"),
# Bundeswehr Reserve (Reserveoffizier ausserhalb des Wehrdienstes). Probed 2026-09-10.
# The portal is SAP E-Recruiting (sap-client 300); the data sits behind an OData service
# at /erece/unreg/ (entity set `Stellensuche_Set`, `SearchCategory eq '0022'` = Reserve,
# ~79 postings). It is NOT wired up, deliberately:
# * the portal actively blocks automation — a headless browser gets an HTTP 404
# "Bundeswehr Fehlerseite" on every path, and filtered OData GETs are 404'd by the WAF;
# the only way through was spoofing the UA and masking navigator.webdriver.
# * that is an access control the operator deliberately set. Routinely circumventing it
# from a scheduled scraper is a different posture from jobs.admin.ch, which serves an
# open, unauthenticated API with no countermeasures. Not a call to make silently.
# BETTER CHANNEL, operator-sanctioned: the portal has a Suchauftrag (job alert) —
# `AttSuchauftragSet` in the OData metadata. It e-mails new matching Reserve postings.
# Zero maintenance, no evasion. Set it up once instead of scraping.
# NOTE on Laufbahn: most IT-titled reserve postings are Feldwebel (NCO) roles. As a
# Leutnant d.R. only the Offizier ones apply — filter on that before reading titles.
("Bundeswehr Reserve", "SAP E-Recruiting behind bot detection; NOT scraped by choice (see code comment). Use the portal's own Suchauftrag/job alert. Filter: Art der Stelle = Stellenangebote Reserve. Watch for Offizier (not Feldwebel) IT roles.",
"https://bewerbung.bundeswehr-karriere.de/erece/portal/index.html"),
]
@@ -1195,6 +1242,92 @@ def fetch_taleo(args):
return list(by_id.values())
def _prospective_unit(attrs):
"""Sub-unit name from a Prospective attributes dict.
The key is `verwaltungseinheit_<id>` where <id> is DEPARTMENT-SPECIFIC — VBS uses
1083398, EFD uses 1083364. Never hardcode one: match the prefix, or the adapter breaks
the moment it is pointed at another department.
"""
for key, val in attrs.items():
if key.startswith("verwaltungseinheit_") and val:
return " / ".join(str(v) for v in val)
return ""
def fetch_prospective(args):
"""Prospective (ohws.prospective.ch) public jobs API.
Verified use: jobs.admin.ch — the Swiss federal job portal (medium 1000624, 463 roles).
jobs.admin.ch itself is an S3-hosted SPA that returns S3 `AccessDenied` for any direct
path fetch, headless browser included, so scraping the site is a dead end. The SPA reads
this API, which is public, unauthenticated, fast and far richer than the rendered page.
Why this board is worth special handling: unlike every other source in the scout, each
row carries a **Lohnklasse** (the admin.ch analogue of SBB's Anforderungsniveau — a
direct read on the comp bar), a hard **application deadline**, and a **named contact**,
which turns a cold submit there into a choice rather than a necessity. It also exposes
the real Aufgaben/Anforderungen text, so scoring runs against the JD body instead of the
title — the failure mode `job_scout_overscoring_findings` records.
Args: medium_id (required), units (list of substrings matched against the sub-unit name;
empty/absent = no unit filter), lang (default "de"), page_size (default 200).
"""
medium = args["medium_id"]
units = [u.lower() for u in args.get("units", [])]
lang = args.get("lang", "de")
page_size = int(args.get("page_size", 200))
base = f"https://ohws.prospective.ch/public/v1/medium/{medium}/jobs"
# Page defensively: one request that happens to fit today is not a contract.
raw, offset, total = [], 0, None
for _ in range(50):
data = http_get_json(f"{base}?lang={lang}&offset={offset}&limit={page_size}")
batch = data.get("jobs") or []
if total is None:
total = data.get("total")
if not batch:
break
raw.extend(batch)
offset += len(batch)
if total is not None and offset >= total:
break
jobs = []
for j in raw:
attrs = j.get("attributes") or {}
unit = _prospective_unit(attrs)
if units and not any(u in unit.lower() for u in units):
continue
szas = j.get("szas") or {}
loc = html.unescape(" / ".join(attrs.get("arbeitsort") or []) or "Schweiz")
lohnklasse = " / ".join(attrs.get("lohnklasse") or [])
deadline = (j.get("end_date") or "")[:10]
# Fold the scoreable JD body plus the fields that drive real decisions (unit,
# Lohnklasse, deadline, contact) into description so the scorer sees them too.
body = " ".join(str(szas.get(k, "")) for k in
("sza_role", "sza_tasks", "sza_requirements", "sza_field_of_activity"))
body = html.unescape(re.sub(r"<[^>]+>", " ", body))
desc = " | ".join(x for x in (
unit,
f"Lohnklasse {lohnklasse}" if lohnklasse else "",
f"Bewerbungsfrist {deadline}" if deadline else "",
str(szas.get("sza_contact", "")),
body,
) if x)
jobs.append({
"id": str(j.get("id") or j.get("viewkey") or ""),
"title": html.unescape((j.get("title") or "").strip()),
# Suffix so the CH location filter matches city-only values like "Zimmerwald".
"location": f"{loc}, Switzerland" if args.get("loc_suffix", True) else loc,
"url": (j.get("links") or {}).get("directlink", ""),
"posted": (j.get("start_date") or "")[:10],
"deadline": deadline,
"description": re.sub(r"\s+", " ", desc)[:1200],
})
return jobs
def fetch_json(args):
"""Generic JSON jobs API with configurable field names, for employer sites that expose
a clean public endpoint. Verified use: Swissgrid (Magnolia CMS
@@ -1585,6 +1718,7 @@ ADAPTERS = {
"lever": fetch_lever,
"taleo": fetch_taleo,
"json": fetch_json,
"prospective": fetch_prospective,
"sbb": fetch_sbb,
"bkw": fetch_bkw,
"playwright": fetch_playwright,
@@ -1947,6 +2081,8 @@ def write_report(path, results, errors, new_only, include_weak, stats=None, tota
lines.append(f"- Location: {r['location']} *({loc_tag})*")
if r.get("posted"):
lines.append(f"- Posted: {r['posted']}")
if r.get("deadline"):
lines.append(f"- **Bewerbungsfrist: {r['deadline']}**")
lines.append(f"- URL: {r['url']}")
if d:
note = f" — {d['note']}" if d.get("note") else ""
@@ -2048,6 +2184,7 @@ def _process_company(cid, display, args, jobs, seen, today, last_scrape):
"company": display, "company_id": cid,
"title": j["title"], "location": j["location"],
"url": j["url"], "posted": j.get("posted", ""),
"deadline": j.get("deadline", ""),
"score": score, "pos": pos, "neg": neg,
"in_ch": in_ch, "remote": is_remote, "relocation": is_relocation,
"is_new": is_new,
@@ -2199,7 +2336,8 @@ def main():
json_path = REPORTS_DIR / f"{today}.json"
dump = [{
"company": r["company"], "title": r["title"], "location": r["location"],
"url": r["url"], "posted": r["posted"], "is_new": r["is_new"],
"url": r["url"], "posted": r["posted"], "deadline": r.get("deadline", ""),
"is_new": r["is_new"],
"decision": dec_index.get(_decision_key(r["url"])),
"keyword_signal": {"score": r["score"], "pos": r["pos"], "neg": r["neg"]},
"loc_flags": {