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:
1 parent
a0ec2fedb5
commit
0d999ab21b
8 files changed
+1084
-23
No files matched your search
+139
-1
@@ -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": {
|
||||
|
||||
Reference in new issue
Block a user