"""Shared helpers for connector families: location / country parsing (never invent), date parsing, job normalisation, fingerprints, JSON access and the ATS vendor → API mapping used by discovery. Deterministic, no network.""" from __future__ import annotations import hashlib import json import re from datetime import UTC, datetime from typing import Any from urllib.parse import urlparse from dateutil import parser as dtparser from companyatlas.fetch import FetchResult from companyatlas.ids import stable_hash from companyatlas.sdk.models import Block, ExtractedJob from companyatlas.sdk.normalize import BLOCK_WEIGHTS, normalize_whitespace, normalized_text, simhash # ------------------------------------------------------------------------------------------------------------ countries _COUNTRIES: dict[str, str] = { "afghanistan": "AF", "albania": "AL", "algeria": "DZ", "andorra": "AD", "angola": "AO", "argentina": "AR", "armenia": "AM", "australia": "AU", "austria": "AT", "österreich": "AT", "azerbaijan": "AZ", "bahamas": "BS", "bahrain": "BH", "bangladesh": "BD", "belarus": "BY", "belgium": "BE", "belgique": "BE", "belgië": "BE", "bolivia": "BO", "bosnia and herzegovina": "BA", "bosnia": "BA", "botswana": "BW", "brazil": "BR", "brasil": "BR", "brunei": "BN", "bulgaria": "BG", "cambodia": "KH", "cameroon": "CM", "canada": "CA", "chile": "CL", "china": "CN", "中国": "CN", "colombia": "CO", "costa rica": "CR", "croatia": "HR", "cuba": "CU", "cyprus": "CY", "czech republic": "CZ", "czechia": "CZ", "denmark": "DK", "danmark": "DK", "dominican republic": "DO", "ecuador": "EC", "egypt": "EG", "el salvador": "SV", "estonia": "EE", "ethiopia": "ET", "finland": "FI", "suomi": "FI", "france": "FR", "georgia": "GE", "germany": "DE", "deutschland": "DE", "ghana": "GH", "greece": "GR", "guatemala": "GT", "honduras": "HN", "hong kong": "HK", "hungary": "HU", "iceland": "IS", "india": "IN", "indonesia": "ID", "iran": "IR", "iraq": "IQ", "ireland": "IE", "israel": "IL", "italy": "IT", "italia": "IT", "jamaica": "JM", "japan": "JP", "日本": "JP", "jordan": "JO", "kazakhstan": "KZ", "kenya": "KE", "kuwait": "KW", "latvia": "LV", "lebanon": "LB", "lithuania": "LT", "luxembourg": "LU", "macau": "MO", "malaysia": "MY", "malta": "MT", "mexico": "MX", "méxico": "MX", "moldova": "MD", "monaco": "MC", "mongolia": "MN", "montenegro": "ME", "morocco": "MA", "myanmar": "MM", "nepal": "NP", "netherlands": "NL", "the netherlands": "NL", "nederland": "NL", "holland": "NL", "new zealand": "NZ", "nicaragua": "NI", "nigeria": "NG", "north macedonia": "MK", "norway": "NO", "norge": "NO", "oman": "OM", "pakistan": "PK", "panama": "PA", "paraguay": "PY", "peru": "PE", "perú": "PE", "philippines": "PH", "poland": "PL", "polska": "PL", "portugal": "PT", "qatar": "QA", "romania": "RO", "românia": "RO", "russia": "RU", "russian federation": "RU", "rwanda": "RW", "saudi arabia": "SA", "senegal": "SN", "serbia": "RS", "singapore": "SG", "slovakia": "SK", "slovenia": "SI", "south africa": "ZA", "south korea": "KR", "korea": "KR", "republic of korea": "KR", "korea, republic of": "KR", "spain": "ES", "españa": "ES", "sri lanka": "LK", "sweden": "SE", "sverige": "SE", "switzerland": "CH", "schweiz": "CH", "suisse": "CH", "taiwan": "TW", "tanzania": "TZ", "thailand": "TH", "tunisia": "TN", "turkey": "TR", "türkiye": "TR", "turkiye": "TR", "uganda": "UG", "ukraine": "UA", "united arab emirates": "AE", "uae": "AE", "united kingdom": "GB", "uk": "GB", "u.k.": "GB", "great britain": "GB", "britain": "GB", "england": "GB", "scotland": "GB", "wales": "GB", "northern ireland": "GB", "united states": "US", "united states of america": "US", "usa": "US", "u.s.": "US", "u.s.a.": "US", "us": "US", "america": "US", "uruguay": "UY", "uzbekistan": "UZ", "venezuela": "VE", "vietnam": "VN", "viet nam": "VN", "zambia": "ZM", "zimbabwe": "ZW", "puerto rico": "PR", "european union": None, # type: ignore[dict-item] } ISO2 = {v for v in _COUNTRIES.values() if v} ISO3 = {"USA": "US", "GBR": "GB", "DEU": "DE", "FRA": "FR", "CAN": "CA", "AUS": "AU", "JPN": "JP", "CHN": "CN", "IND": "IN", "BRA": "BR", "ESP": "ES", "ITA": "IT", "NLD": "NL", "SWE": "SE", "CHE": "CH", "SGP": "SG", "IRL": "IE", "MEX": "MX", "KOR": "KR", "POL": "PL", "BEL": "BE", "AUT": "AT", "DNK": "DK", "NOR": "NO", "FIN": "FI", "PRT": "PT", "ISR": "IL", "ARE": "AE", "NZL": "NZ", "ZAF": "ZA", "ARG": "AR", "CZE": "CZ", "HUN": "HU"} US_STATES: dict[str, str] = { "alabama": "AL", "alaska": "AK", "arizona": "AZ", "arkansas": "AR", "california": "CA", "colorado": "CO", "connecticut": "CT", "delaware": "DE", "florida": "FL", "georgia": "GA", "hawaii": "HI", "idaho": "ID", "illinois": "IL", "indiana": "IN", "iowa": "IA", "kansas": "KS", "kentucky": "KY", "louisiana": "LA", "maine": "ME", "maryland": "MD", "massachusetts": "MA", "michigan": "MI", "minnesota": "MN", "mississippi": "MS", "missouri": "MO", "montana": "MT", "nebraska": "NE", "nevada": "NV", "new hampshire": "NH", "new jersey": "NJ", "new mexico": "NM", "new york": "NY", "north carolina": "NC", "north dakota": "ND", "ohio": "OH", "oklahoma": "OK", "oregon": "OR", "pennsylvania": "PA", "rhode island": "RI", "south carolina": "SC", "south dakota": "SD", "tennessee": "TN", "texas": "TX", "utah": "UT", "vermont": "VT", "virginia": "VA", "washington": "WA", "west virginia": "WV", "wisconsin": "WI", "wyoming": "WY", "district of columbia": "DC", } # two-letter US state codes that are NOT also ISO country codes (safe to infer US from "City, ST") US_STATE_CODES_SAFE = {c for c in US_STATES.values()} - {"CA", "CO", "DE", "IN", "ME", "MD", "MT", "NE", "MA", "MO", "MN", "NC", "SC", "LA", "ID", "IL", "GA", "AL", "AR", "AZ", "KY", "PA", "TN", "VA", "MS"} CA_PROVINCES: dict[str, str] = {"ontario": "ON", "quebec": "QC", "québec": "QC", "british columbia": "BC", "alberta": "AB", "manitoba": "MB", "saskatchewan": "SK", "nova scotia": "NS", "new brunswick": "NB", "newfoundland and labrador": "NL", "prince edward island": "PE"} CA_PROVINCE_CODES_SAFE = {"ON", "QC", "BC", "AB", "MB", "SK", "NS", "NB"} # two-letter tokens that are both a US state/CA province code and an ISO country code: never resolved to a country on their own AMBIGUOUS_CODES = (set(US_STATES.values()) | set(CA_PROVINCES.values())) & ISO2 REMOTE_RE = re.compile(r"\b(remote|work from home|wfh|anywhere|distributed|télétravail|homeoffice|home office)\b", re.IGNORECASE) HYBRID_RE = re.compile(r"\bhybrid\b", re.IGNORECASE) POSTAL_RE = re.compile(r"\b[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}\b|\b\d{4}\s?[A-Z]{2}\b|\b\d{4,6}(?:-\d{4})?\b|\b[A-Z]\d[A-Z]\s*\d[A-Z]\d\b") # UK · NL · numeric · CA def country_code(text: str | None) -> str | None: """ISO-2 for a country name / ISO-2 / ISO-3 token — None unless confident.""" if not text: return None t = normalize_whitespace(text).strip(" .,;()").lower() if not t: return None if t in _COUNTRIES: return _COUNTRIES[t] up = t.upper() if len(up) == 2 and up in ISO2: return up if len(up) == 3 and up in ISO3: return ISO3[up] return None def _unambiguous_country(token: str) -> str | None: t = token.strip() if len(t) == 2 and t.upper() in AMBIGUOUS_CODES: return None return country_code(t) def norm_name(text: str) -> str: """Entity key for people/products/locations (`name_norm`): ASCII, lowercase, single spaces, punctuation dropped.""" from slugify import slugify as _slug return _slug(text or "", lowercase=True, regex_pattern=r"[^a-z0-9]+").replace("-", " ").strip()[:120] def parse_location(text: str | None) -> dict[str, Any]: """'San Francisco, CA, USA' → {city, region, country, remote}. Only sets what the text states; never guesses a country.""" out: dict[str, Any] = {"city": None, "region": None, "country": None, "remote": None} if not text: return out raw = normalize_whitespace(text) if REMOTE_RE.search(raw): out["remote"] = True elif HYBRID_RE.search(raw): out["remote"] = False cleaned = re.sub(r"\((?:remote|hybrid|on-?site)[^)]*\)", "", raw, flags=re.IGNORECASE) cleaned = re.sub(r"\b(remote|hybrid|on-?site|flexible|multiple locations|or)\b\s*[-–—,/]?\s*", "", cleaned, flags=re.IGNORECASE) cleaned = POSTAL_RE.sub(" ", cleaned).strip(" ,-–/") parts = [p.strip() for p in re.split(r"\s*[,|•·/;]\s*|\s+[-–—]\s+", cleaned) if p.strip()] if not parts: return out if len(parts) >= 2 and _unambiguous_country(parts[0]) and not _unambiguous_country(parts[-1]): parts.reverse() # Workday style "US, MA, Westford" → city last → first country = _unambiguous_country(parts[-1]) if country: parts = parts[:-1] elif len(parts) == 1: # a bare token: a country name ("Germany") is a country; anything else is left alone (a city without a country is not guessed) return out out["country"] = country if parts: last = parts[-1] low = last.lower() two = last.upper() if len(last) == 2 and last.isalpha() else None state = US_STATES.get(low) or (two if two in US_STATE_CODES_SAFE else None) prov = CA_PROVINCES.get(low) or (two if two in CA_PROVINCE_CODES_SAFE else None) if state and (country in (None, "US")): out["region"], out["country"] = state, "US" parts = parts[:-1] elif prov and (country in (None, "CA")): out["region"], out["country"] = prov, "CA" parts = parts[:-1] elif two and two in AMBIGUOUS_CODES and len(parts) >= 2: out["region"] = two # "San Francisco, CA": region kept, country deliberately unknown parts = parts[:-1] elif len(parts) >= 2: out["region"] = last[:80] parts = parts[:-1] if parts: city = parts[0] if len(city) <= 80 and not re.search(r"\d{3,}", city): out["city"] = city return out # ------------------------------------------------------------------------------------------------------------ dates DATE_IN_TEXT_RE = re.compile( r"(\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?)|" r"((?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\.?\s+\d{1,2}(?:st|nd|rd|th)?,?\s+\d{4})|" r"(\d{1,2}(?:st|nd|rd|th)?\s+(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\.?,?\s+\d{4})|" r"(\d{1,2}[/.]\d{1,2}[/.]\d{4})", re.IGNORECASE) DATE_IN_URL_RE = re.compile(r"/((?:19|20)\d{2})/(0?[1-9]|1[0-2])(?:/(0?[1-9]|[12]\d|3[01]))?(?:/|$)") def parse_date(value: Any) -> datetime | None: """Best-effort aware datetime (UTC) from ISO strings, epoch seconds/ms, common textual dates. None when unparseable.""" if value is None or value == "": return None if isinstance(value, datetime): return value if value.tzinfo else value.replace(tzinfo=UTC) if isinstance(value, int | float): v = float(value) if v > 1e12: v /= 1000.0 if 0 < v < 4102444800: return datetime.fromtimestamp(v, tz=UTC) return None s = str(value).strip() if not s or len(s) > 60: return None if s.isdigit(): return parse_date(int(s)) try: dt = dtparser.parse(s, fuzzy=False, dayfirst=False) except (ValueError, OverflowError, TypeError): try: dt = dtparser.parse(s, fuzzy=True) except (ValueError, OverflowError, TypeError): return None if dt.year < 1990 or dt.year > 2100: return None return dt if dt.tzinfo else dt.replace(tzinfo=UTC) def date_from_text(text: str | None) -> datetime | None: if not text: return None m = DATE_IN_TEXT_RE.search(text) return parse_date(m.group(0)) if m else None def date_from_url(url: str | None) -> datetime | None: if not url: return None m = DATE_IN_URL_RE.search(urlparse(url).path) if not m: return None y, mo, d = int(m.group(1)), int(m.group(2)), int(m.group(3) or 1) try: return datetime(y, mo, d, tzinfo=UTC) except ValueError: return None # ------------------------------------------------------------------------------------------------------------ jobs SENIORITY_RULES: list[tuple[str, re.Pattern[str]]] = [ ("intern", re.compile(r"\b(intern(ship)?|stagiaire|werkstudent|working student|apprentice|co-op)\b", re.IGNORECASE)), ("c_level", re.compile(r"\b(chief\b|\bc[a-z]o\b|vp\b|vice president|evp|svp|head of|director)", re.IGNORECASE)), ("principal", re.compile(r"\b(principal|distinguished|fellow)\b", re.IGNORECASE)), ("staff", re.compile(r"\bstaff\b", re.IGNORECASE)), ("lead", re.compile(r"\b(lead|manager|leiter)\b", re.IGNORECASE)), ("senior", re.compile(r"\b(senior|sr\.?|iii|iv)\b", re.IGNORECASE)), ("junior", re.compile(r"\b(junior|jr\.?|entry[- ]level|graduate|associate|i)\b", re.IGNORECASE)), ] ENGINEERING_RE = re.compile(r"\b(engineer|engineering|developer|software|sre|devops|architect|data scientist|machine learning|backend|frontend|" r"full[- ]stack|qa|security|infrastructure|platform|embedded|firmware|ios|android|ingénieur|entwickler)\b", re.IGNORECASE) EMPLOYMENT_TYPES = {"full-time": "full_time", "full time": "full_time", "fulltime": "full_time", "permanent": "full_time", "part-time": "part_time", "part time": "part_time", "contract": "contract", "contractor": "contract", "temporary": "temporary", "temp": "temporary", "intern": "internship", "internship": "internship", "freelance": "contract", "apprenticeship": "internship", "seasonal": "temporary", "fixed-term": "contract", "fixed term": "contract", "cdi": "full_time", "cdd": "contract"} def seniority_guess(title: str | None) -> str | None: if not title: return None for label, pat in SENIORITY_RULES: if pat.search(title): return label return None def employment_type_norm(value: str | None) -> str | None: if not value: return None v = normalize_whitespace(str(value)).lower().replace("_", "-") if v in EMPLOYMENT_TYPES: return EMPLOYMENT_TYPES[v] for k, out in EMPLOYMENT_TYPES.items(): if k in v: return out return v[:30] def is_engineering(title: str | None) -> bool: return bool(title and ENGINEERING_RE.search(title)) def job_fingerprint(title: str, location_text: str | None, external_id: str | None, url: str | None) -> str: ident = (external_id or "").strip() or (url or "").split("?")[0].rstrip("/") return stable_hash(normalized_text(title), normalized_text(location_text or ""), ident.lower()) def strip_html(s: str | None) -> str: if not s: return "" from selectolax.lexbor import LexborHTMLParser try: return normalize_whitespace(LexborHTMLParser(f"