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:
@@ -1 +1,2 @@
|
|||||||
playwright>=1.40,<2
|
playwright>=1.40,<2
|
||||||
|
certifi
|
||||||
|
|||||||
+105
-24
@@ -24,6 +24,7 @@ See the adapter-coverage notes at the bottom for the current automated/manual sp
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import ssl
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
@@ -44,20 +45,31 @@ CH_LOCATION_KEYWORDS = [
|
|||||||
"lausanne", "zug", "rüschlikon", "stäfa", "schweiz", "suisse",
|
"lausanne", "zug", "rüschlikon", "stäfa", "schweiz", "suisse",
|
||||||
]
|
]
|
||||||
|
|
||||||
REMOTE_KEYWORDS = ["remote", "home based", "home-based", "anywhere", "distributed"]
|
|
||||||
|
|
||||||
US_ONLY_PATTERNS = [
|
US_ONLY_PATTERNS = [
|
||||||
"remote - us", "remote, us", "remote-us", "us remote", "us-remote",
|
"remote - us", "remote, us", "remote-us", "us remote", "us-remote",
|
||||||
"remote-friendly us", "remote (us)", "united states - remote",
|
"remote-friendly us", "remote (us)", "united states - remote",
|
||||||
"remote, united states",
|
"remote, united states",
|
||||||
]
|
]
|
||||||
|
|
||||||
EU_HINT_KEYWORDS = [
|
# Pan-regional/global remote — genuinely reachable from Switzerland regardless of which
|
||||||
"germany", "france", "spain", "portugal", "ireland", "netherlands",
|
# single country the listing happens to name.
|
||||||
"sweden", "norway", "finland", "denmark", "poland", "czech",
|
GLOBAL_REMOTE_KEYWORDS = ["europe", "emea", "global", "worldwide"]
|
||||||
"romania", "italy", "austria", "belgium", "uk", "united kingdom",
|
|
||||||
"europe", "emea", "global", "worldwide",
|
# Single-country hints, grouped so synonyms ("uk"/"united kingdom") count as one country.
|
||||||
] + CH_LOCATION_KEYWORDS
|
# 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 = {
|
POSITIVE_KEYWORDS = {
|
||||||
"genai": 3, "generative ai": 3, "llm": 3, "large language model": 3,
|
"genai": 3, "generative ai": 3, "llm": 3, "large language model": 3,
|
||||||
@@ -69,6 +81,10 @@ POSITIVE_KEYWORDS = {
|
|||||||
"data scientist": 2, "data science": 2,
|
"data scientist": 2, "data science": 2,
|
||||||
"solutions architect": 2, "platform engineer": 2,
|
"solutions architect": 2, "platform engineer": 2,
|
||||||
"ai infrastructure": 2, "inference": 2, "rag": 2, "agentic": 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,
|
"kubernetes": 1, "docker": 1, "etl": 1, "pipeline": 1,
|
||||||
# Core CV lane — DevOps / data-platform / cloud (was scoring 0; surfaced only via "senior")
|
# Core CV lane — DevOps / data-platform / cloud (was scoring 0; surfaced only via "senior")
|
||||||
"data platform": 3, "platform engineering": 2, "devops": 2,
|
"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
|
# 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 —
|
# (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).
|
# 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
|
("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
|
("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
|
("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).
|
# 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}),
|
("metgroup", "MET Group", "smartrecruiters", {"company": "METGroup", "_title_filter": ENG_TITLE_FILTER}),
|
||||||
("ldc", "Louis Dreyfus","smartrecruiters",{"company": "LouisDreyfusCompany", "_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.
|
# 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).
|
# Low-volume RSS feed; no title filter (Innovation Hub roles can be oddly titled).
|
||||||
("bis", "BIS (Basel)","rss", {
|
("bis", "BIS (Basel)","rss", {
|
||||||
@@ -328,6 +354,25 @@ COMPANIES = [
|
|||||||
"next_button": "#pfch-pagination-next",
|
"next_button": "#pfch-pagination-next",
|
||||||
"max_pages": 10,
|
"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.
|
# 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"):
|
def http_get_json(url, headers=None, data=None, method="GET"):
|
||||||
headers = headers or {}
|
headers = headers or {}
|
||||||
headers.setdefault("User-Agent", USER_AGENT)
|
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")
|
data = json.dumps(data).encode("utf-8")
|
||||||
headers.setdefault("Content-Type", "application/json")
|
headers.setdefault("Content-Type", "application/json")
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
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"))
|
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."""
|
RSS 2.0 <item> elements. Location isn't in the feed, so default_location is required."""
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
req = urllib.request.Request(args["url"], headers={"User-Agent": USER_AGENT})
|
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())
|
root = ET.fromstring(resp.read())
|
||||||
ns = {"rss1": "http://purl.org/rss/1.0/", "dc": "http://purl.org/dc/elements/1.1/"}
|
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")
|
items = root.findall(".//rss1:item", ns) or root.findall(".//item")
|
||||||
@@ -606,7 +665,7 @@ def fetch_onlyfy(args):
|
|||||||
req = urllib.request.Request(url, headers={
|
req = urllib.request.Request(url, headers={
|
||||||
"User-Agent": USER_AGENT, "X-Requested-With": "XMLHttpRequest",
|
"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")
|
page = resp.read().decode("utf-8", "replace")
|
||||||
titles = re.findall(r'<a href="(/job/[a-z0-9]+)">(.*?)</a>', page, re.S)
|
titles = re.findall(r'<a href="(/job/[a-z0-9]+)">(.*?)</a>', page, re.S)
|
||||||
locs = re.findall(r'icon-map-marker[^>]*></i>\s*([^<]+)', page)
|
locs = re.findall(r'icon-map-marker[^>]*></i>\s*([^<]+)', page)
|
||||||
@@ -987,18 +1046,23 @@ def location_matches(loc_text):
|
|||||||
return False, False
|
return False, False
|
||||||
low = loc_text.lower()
|
low = loc_text.lower()
|
||||||
in_ch = any(k in low for k in CH_LOCATION_KEYWORDS)
|
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
|
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)
|
is_global_wide = any(k in low for k in GLOBAL_REMOTE_KEYWORDS) and not is_us_only
|
||||||
# Pan-European postings (location literally "Europe"/"EMEA", e.g. QuantCo's Lever board)
|
country_hits = sum(1 for group in COUNTRY_HINT_GROUPS if any(k in low for k in group))
|
||||||
# are reachable for a DACH-based candidate even without an explicit "remote" keyword, so
|
is_multi_country = country_hits >= 2 and not is_us_only
|
||||||
# treat them as eligible too. City-specific EU roles (e.g. "Berlin or Munich") stay out.
|
is_remote = is_global_wide or is_multi_country
|
||||||
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
|
|
||||||
return in_ch, is_remote
|
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)
|
@lru_cache(maxsize=512)
|
||||||
def _kw_pattern(kw):
|
def _kw_pattern(kw):
|
||||||
"""Word-boundary regex for a keyword. Plain substring matching produced false hits
|
"""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"])
|
d = decisions.get(r["url"])
|
||||||
new_tag = " [NEW]" if r["is_new"] else ""
|
new_tag = " [NEW]" if r["is_new"] else ""
|
||||||
decided_tag = f" — 🗂 {d['decision'].upper()}" if d 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"### [{r['score']}] {r['company']} - {r['title']}{new_tag}{decided_tag}")
|
||||||
lines.append(f"- Location: {r['location']} *({loc_tag})*")
|
lines.append(f"- Location: {r['location']} *({loc_tag})*")
|
||||||
if r.get("posted"):
|
if r.get("posted"):
|
||||||
@@ -1254,7 +1320,8 @@ def main():
|
|||||||
for j in jobs:
|
for j in jobs:
|
||||||
jid = str(j.get("id") or j.get("url"))
|
jid = str(j.get("id") or j.get("url"))
|
||||||
in_ch, is_remote = location_matches(j.get("location", ""))
|
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
|
continue
|
||||||
# Collapse the same role posted once per remote country (title differs only
|
# Collapse the same role posted once per remote country (title differs only
|
||||||
# by a "| Country | Remote" suffix) — dedupe on the title before the first "|".
|
# by a "| Country | Remote" suffix) — dedupe on the title before the first "|".
|
||||||
@@ -1278,9 +1345,11 @@ def main():
|
|||||||
"title": j["title"], "location": j["location"],
|
"title": j["title"], "location": j["location"],
|
||||||
"url": j["url"], "posted": j.get("posted", ""),
|
"url": j["url"], "posted": j.get("posted", ""),
|
||||||
"score": score, "pos": pos, "neg": neg,
|
"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,
|
stats.append({"company": display, "scraped": scraped, "eligible": eligible,
|
||||||
"match": match, "newest": newest,
|
"match": match, "newest": newest,
|
||||||
@@ -1301,6 +1370,18 @@ def main():
|
|||||||
stats=stats, total_secs=total_secs,
|
stats=stats, total_secs=total_secs,
|
||||||
decisions=decisions, hide_decided=hide_decided)
|
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"])
|
n_new = sum(1 for r in all_results if r["is_new"])
|
||||||
print(f"\nReport written: {report_path}", file=sys.stderr)
|
print(f"\nReport written: {report_path}", file=sys.stderr)
|
||||||
print(f"Total matches: {len(all_results)} ({n_new} new) | "
|
print(f"Total matches: {len(all_results)} ({n_new} new) | "
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user