feat(job_scout): add Equinor/BFH, fix Windows SSL cert errors, tighten remote-location matching

Equinor (Workday) and BFH (Bern, playwright) wired up as new sources; artificial
intelligence/analytics-engineer keywords added so int'l-org listings (BIS) stop scoring
as noise. certifi-backed SSL context works around Workday certs failing verification
against a stale Windows root store.

location_matches() previously treated "remote + any single named EU country" as
CH-eligible, which let single-country residency-locked listings (e.g. Databricks'
"Remote - United Kingdom") pass as if they were open EU-wide. Now requires either a
pan-regional keyword (Europe/EMEA/Global/Worldwide) or 2+ distinct countries named.
Denmark is carved out as its own always-on relocation category (on-site or remote,
any company), replacing the previous Databricks-only opt-in.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 21:01:03 +02:00
co-authored by Claude Sonnet 5
parent 9645dbca8d
commit 3ad39117d6
3 changed files with 1464 additions and 24 deletions
+1
View File
@@ -1 +1,2 @@
playwright>=1.40,<2
certifi
+105 -24
View File
@@ -24,6 +24,7 @@ See the adapter-coverage notes at the bottom for the current automated/manual sp
import json
import re
import ssl
import sys
import time
from functools import lru_cache
@@ -44,20 +45,31 @@ CH_LOCATION_KEYWORDS = [
"lausanne", "zug", "rüschlikon", "stäfa", "schweiz", "suisse",
]
REMOTE_KEYWORDS = ["remote", "home based", "home-based", "anywhere", "distributed"]
US_ONLY_PATTERNS = [
"remote - us", "remote, us", "remote-us", "us remote", "us-remote",
"remote-friendly us", "remote (us)", "united states - remote",
"remote, united states",
]
EU_HINT_KEYWORDS = [
"germany", "france", "spain", "portugal", "ireland", "netherlands",
"sweden", "norway", "finland", "denmark", "poland", "czech",
"romania", "italy", "austria", "belgium", "uk", "united kingdom",
"europe", "emea", "global", "worldwide",
] + CH_LOCATION_KEYWORDS
# Pan-regional/global remote — genuinely reachable from Switzerland regardless of which
# single country the listing happens to name.
GLOBAL_REMOTE_KEYWORDS = ["europe", "emea", "global", "worldwide"]
# Single-country hints, grouped so synonyms ("uk"/"united kingdom") count as one country.
# A listing naming only ONE of these is a residency lock (e.g. Databricks' "Remote - United
# Kingdom Remote - United Kingdom"), not something open to a CH-based candidate, even though
# it reads as "remote." Naming 2+ reads as a broad multi-country remote policy instead (e.g.
# Grafana's "Germany | Ireland | Spain | Sweden | UK") — that's treated as eligible.
COUNTRY_HINT_GROUPS = [
("germany",), ("france",), ("spain",), ("portugal",), ("ireland",),
("netherlands",), ("sweden",), ("norway",), ("finland",), ("poland",),
("czech",), ("romania",), ("italy",), ("austria",), ("belgium",),
("uk", "united kingdom"),
]
# Countries outside CH where relocation is genuinely on the table for a strong enough offer
# (see project_target_companies memory) — accepted for every company, on-site or remote.
RELOCATION_COUNTRY_KEYWORDS = ["denmark", "copenhagen"]
POSITIVE_KEYWORDS = {
"genai": 3, "generative ai": 3, "llm": 3, "large language model": 3,
@@ -69,6 +81,10 @@ POSITIVE_KEYWORDS = {
"data scientist": 2, "data science": 2,
"solutions architect": 2, "platform engineer": 2,
"ai infrastructure": 2, "inference": 2, "rag": 2, "agentic": 2,
# Spelled-out "Artificial Intelligence" (int'l orgs like BIS write it out instead of
# "AI") and analytics-engineer variants — was missing entirely, dropped a genuinely
# on-thesis BIS role (Senior Data & Analytics Engineer - AI) into the noise bucket.
"artificial intelligence": 3, "analytics engineer": 2, "data analytics": 2,
"kubernetes": 1, "docker": 1, "etl": 1, "pipeline": 1,
# Core CV lane — DevOps / data-platform / cloud (was scoring 0; surfaced only via "senior")
"data platform": 3, "platform engineering": 2, "devops": 2,
@@ -156,7 +172,7 @@ COMPANIES = [
# Added 2026-06-06 (Tier A/B data-infra). Databricks/Snowflake/Datadog have Zürich offices
# (Swiss-scale comp, clears bar); Elastic/dbt Labs are remote-EU (verify CH-equiv comp —
# may be geo-banded below 180k, like Grafana). All title-filtered (boards are 160-760 roles).
("databricks","Databricks","greenhouse", {"board": "databricks", "_title_filter": ENG_TITLE_FILTER}), # Zürich SWE + remote-EU
("databricks","Databricks","greenhouse", {"board": "databricks", "_title_filter": ENG_TITLE_FILTER}), # Zürich SWE + remote-EU; Denmark relocation handled globally, see RELOCATION_COUNTRY_KEYWORDS
("snowflake", "Snowflake", "ashby", {"slug": "snowflake", "_title_filter": ENG_TITLE_FILTER}), # Zürich "Observe" observability SWE roles
("datadog", "Datadog", "greenhouse", {"board": "datadog", "_title_filter": ENG_TITLE_FILTER}), # Zürich branch + remote-EU
("elastic", "Elastic", "greenhouse", {"board": "elastic", "_title_filter": ENG_TITLE_FILTER}), # remote-first; ELK = his stack
@@ -166,6 +182,16 @@ COMPANIES = [
# Dropped: Sygnum (Glassdoor 3.4, 51% recommend, comp 2.3/5 — below 180k bar — 2026-05).
("metgroup", "MET Group", "smartrecruiters", {"company": "METGroup", "_title_filter": ENG_TITLE_FILTER}),
("ldc", "Louis Dreyfus","smartrecruiters",{"company": "LouisDreyfusCompany", "_title_filter": ENG_TITLE_FILTER}),
# Equinor (Workday) — Norway energy major; he's lived/worked in NO before (Equinor AI
# Architect req was applied to manually — see CLAUDE.md Active Sessions) and it's an
# energy-trading-adjacent target. Live board is small (~15 total) and mostly NO/US/BR
# on-site ops/trading roles, so no server-side search_text filter or title filter —
# let location_matches do the CH/remote-eligibility work like the other low-volume boards.
("equinor", "Equinor", "workday", {
"host": "equinor.wd3.myworkdayjobs.com",
"tenant": "equinor",
"site": "EQNR",
}),
# 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", {
@@ -328,6 +354,25 @@ COMPANIES = [
"next_button": "#pfch-pagination-next",
"max_pages": 10,
}),
# BFH (Bern University of Applied Sciences). Re-added 2026-07-14: the jobs.bfh.ch domain
# itself is a broken/stub SPA shell (renders "Career Center project template", nothing
# else — this is what looked like a 403 previously), but the actual listing widget is
# embedded and fully renders on the www.bfh.ch careers page. Card = one <div> per posting
# with 3 li.teaser-text-list-item fields (department, location, employment type); location
# is the 2nd. Low-volume (12 open reqs), single page. German titles score 0 under the
# English-keyword scorer (same problem as SBB/BKW) — _score_floor keeps them visible
# instead of hidden as "noise", e.g. "Professur Data-Driven Government",
# "Co-Leitung ... digitale Souveränität".
("bfh", "BFH (Bern)", "playwright", {
"url": "https://www.bfh.ch/en/about-bfh/careers/jobs/",
"wait_for": ".teas-prospective-job-teaser",
"card": ".teas-prospective-job-teaser",
"title_sel": "h2.teaser-title",
"location_sel": "li.teaser-text-list-item:nth-of-type(2)",
"link_sel": "a",
"default_location": "Switzerland",
"_score_floor": 2,
}),
]
# Companies where adapter probing did not yield a reliable scrape. Reasons noted.
@@ -351,6 +396,20 @@ MANUAL_CHECK = [
]
@lru_cache(maxsize=1)
def _ssl_context():
"""Prefer certifi's CA bundle over the OS trust store: on Windows, urllib's default
context pulls trust anchors from the system cert store, which has been observed to
reject valid Workday-hosted certs as expired (stale cached root) even though the
live chain verifies fine elsewhere. Falls back to the default context if certifi
isn't installed (pip install -r requirements.txt covers it)."""
try:
import certifi
return ssl.create_default_context(cafile=certifi.where())
except ImportError:
return ssl.create_default_context()
def http_get_json(url, headers=None, data=None, method="GET"):
headers = headers or {}
headers.setdefault("User-Agent", USER_AGENT)
@@ -359,7 +418,7 @@ def http_get_json(url, headers=None, data=None, method="GET"):
data = json.dumps(data).encode("utf-8")
headers.setdefault("Content-Type", "application/json")
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=30) as resp:
with urllib.request.urlopen(req, timeout=30, context=_ssl_context()) as resp:
return json.loads(resp.read().decode("utf-8"))
@@ -505,7 +564,7 @@ def fetch_rss(args):
RSS 2.0 <item> elements. Location isn't in the feed, so default_location is required."""
import xml.etree.ElementTree as ET
req = urllib.request.Request(args["url"], headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req, timeout=30) as resp:
with urllib.request.urlopen(req, timeout=30, context=_ssl_context()) as resp:
root = ET.fromstring(resp.read())
ns = {"rss1": "http://purl.org/rss/1.0/", "dc": "http://purl.org/dc/elements/1.1/"}
items = root.findall(".//rss1:item", ns) or root.findall(".//item")
@@ -606,7 +665,7 @@ def fetch_onlyfy(args):
req = urllib.request.Request(url, headers={
"User-Agent": USER_AGENT, "X-Requested-With": "XMLHttpRequest",
})
with urllib.request.urlopen(req, timeout=30) as resp:
with urllib.request.urlopen(req, timeout=30, context=_ssl_context()) as resp:
page = resp.read().decode("utf-8", "replace")
titles = re.findall(r'<a href="(/job/[a-z0-9]+)">(.*?)</a>', page, re.S)
locs = re.findall(r'icon-map-marker[^>]*></i>\s*([^<]+)', page)
@@ -987,18 +1046,23 @@ def location_matches(loc_text):
return False, False
low = loc_text.lower()
in_ch = any(k in low for k in CH_LOCATION_KEYWORDS)
has_remote = any(k in low for k in REMOTE_KEYWORDS)
is_us_only = any(p in low for p in US_ONLY_PATTERNS) and not in_ch
has_eu_hint = any(k in low for k in EU_HINT_KEYWORDS)
# Pan-European postings (location literally "Europe"/"EMEA", e.g. QuantCo's Lever board)
# are reachable for a DACH-based candidate even without an explicit "remote" keyword, so
# treat them as eligible too. City-specific EU roles (e.g. "Berlin or Munich") stay out.
is_eu_wide = any(k in low for k in ("europe", "emea")) and not is_us_only
# Count as remote/EU-eligible only if it isn't a US-only listing and has an EU/global hint
is_remote = (has_remote or is_eu_wide) and not is_us_only and has_eu_hint
is_global_wide = any(k in low for k in GLOBAL_REMOTE_KEYWORDS) and not is_us_only
country_hits = sum(1 for group in COUNTRY_HINT_GROUPS if any(k in low for k in group))
is_multi_country = country_hits >= 2 and not is_us_only
is_remote = is_global_wide or is_multi_country
return in_ch, is_remote
def is_relocation_location(loc_text):
"""Denmark-only relocation acceptance (see RELOCATION_COUNTRY_KEYWORDS), applied to
every company — not a per-company opt-in."""
if not loc_text:
return False
low = loc_text.lower()
return any(k in low for k in RELOCATION_COUNTRY_KEYWORDS)
@lru_cache(maxsize=512)
def _kw_pattern(kw):
"""Word-boundary regex for a keyword. Plain substring matching produced false hits
@@ -1157,7 +1221,9 @@ def write_report(path, results, errors, new_only, include_weak, stats=None, tota
d = decisions.get(r["url"])
new_tag = " [NEW]" if r["is_new"] else ""
decided_tag = f" — 🗂 {d['decision'].upper()}" if d else ""
loc_tag = "CH" if r["in_ch"] else ("Remote" if r["remote"] else "?")
loc_tag = ("CH" if r["in_ch"] else
"Remote" if r["remote"] else
"Relocation" if r.get("relocation") else "?")
lines.append(f"### [{r['score']}] {r['company']} - {r['title']}{new_tag}{decided_tag}")
lines.append(f"- Location: {r['location']} *({loc_tag})*")
if r.get("posted"):
@@ -1254,7 +1320,8 @@ def main():
for j in jobs:
jid = str(j.get("id") or j.get("url"))
in_ch, is_remote = location_matches(j.get("location", ""))
if not (in_ch or is_remote):
is_relocation = is_relocation_location(j.get("location", ""))
if not (in_ch or is_remote or is_relocation):
continue
# Collapse the same role posted once per remote country (title differs only
# by a "| Country | Remote" suffix) — dedupe on the title before the first "|".
@@ -1278,9 +1345,11 @@ def main():
"title": j["title"], "location": j["location"],
"url": j["url"], "posted": j.get("posted", ""),
"score": score, "pos": pos, "neg": neg,
"in_ch": in_ch, "remote": is_remote, "is_new": is_new,
"in_ch": in_ch, "remote": is_remote, "relocation": is_relocation,
"is_new": is_new,
})
company_seen[jid] = {"title": j["title"], "first_seen": today}
if is_new:
company_seen[jid] = {"title": j["title"], "first_seen": today}
stats.append({"company": display, "scraped": scraped, "eligible": eligible,
"match": match, "newest": newest,
@@ -1301,6 +1370,18 @@ def main():
stats=stats, total_secs=total_secs,
decisions=decisions, hide_decided=hide_decided)
# Plain data dump of every eligible role, unfiltered by keyword score — fit judgment
# for these is done by Claude in conversation against the profile memory, not by the
# keyword scorer (which is a rough legacy signal, kept below as "pos"/"neg" hints only).
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"],
"decision": decisions.get(r["url"]),
"keyword_signal": {"score": r["score"], "pos": r["pos"], "neg": r["neg"]},
} for r in all_results]
json_path.write_text(json.dumps(dump, indent=2, ensure_ascii=False), encoding="utf-8")
n_new = sum(1 for r in all_results if r["is_new"])
print(f"\nReport written: {report_path}", file=sys.stderr)
print(f"Total matches: {len(all_results)} ({n_new} new) | "
File diff suppressed because it is too large Load Diff