"""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"
{s}
").text(separator=" ")) except Exception: # noqa: BLE001 return normalize_whitespace(re.sub(r"<[^>]+>", " ", s)) def description_hash(s: str | None) -> str | None: txt = normalized_text(strip_html(s)) if s else "" return hashlib.sha256(txt.encode("utf-8")).hexdigest()[:32] if txt else None def finish_job(job: ExtractedJob) -> ExtractedJob: """Fill derived fields (seniority, remote/city/country from location text) without overriding vendor-provided values.""" job.title = normalize_whitespace(job.title)[:300] if job.location_text: loc = parse_location(job.location_text) job.city = job.city or loc["city"] job.region = job.region or loc["region"] job.country = job.country or loc["country"] if job.remote is None: job.remote = loc["remote"] if job.remote is None and REMOTE_RE.search(job.title): job.remote = True job.seniority = job.seniority or seniority_guess(job.title) job.employment_type = employment_type_norm(job.employment_type) if job.country: job.country = job.country.upper()[:2] return job def job_blocks(jobs: list[ExtractedJob], *, path: str = "Jobs") -> list[Block]: """One stable block per job (identity = external id / url), so the block diff mirrors the job delta exactly.""" blocks: list[Block] = [] seen: set[str] = set() for i, j in enumerate(jobs): ident = (j.external_id or j.url or job_fingerprint(j.title, j.location_text, None, None)) key = f"job_listing:{hashlib.blake2b(str(ident).encode('utf-8'), digest_size=6).hexdigest()}" if key in seen: key = f"{key}#{i}" seen.add(key) text = " — ".join(x for x in (j.title, j.location_text, j.department) if x) blocks.append(Block(key=key, kind="job_listing", text=text, path=path, hash=hashlib.sha256(normalized_text(text).encode()).hexdigest()[:16], simhash=simhash(text), weight=BLOCK_WEIGHTS["job_listing"], order=i, attrs={"url": j.url, "external_id": j.external_id})) return blocks def jobs_text(jobs: list[ExtractedJob], header: str) -> str: lines = [header] + [" — ".join(x for x in (j.title, j.location_text, j.department) if x) for j in jobs] return "\n".join(lines) # ------------------------------------------------------------------------------------------------------------ JSON helpers def load_json(result: FetchResult) -> Any: try: return result.json() except Exception as exc: raise ValueError(f"invalid JSON from {result.final_url}: {exc.__class__.__name__}") from exc def dig(obj: Any, *path: str, default: Any = None) -> Any: cur = obj for p in path: if isinstance(cur, dict): cur = cur.get(p) else: return default if cur is None: return default return cur def merged_result(first: FetchResult, payload: Any, *, pages: int) -> FetchResult: """Synthetic FetchResult holding the merged pages of a paginated public API (what we archive is what we saw).""" content = json.dumps(payload, ensure_ascii=False).encode("utf-8") res = FetchResult(url=first.url, final_url=first.final_url, status=first.status, headers=dict(first.headers), content=content, content_type="application/json; charset=utf-8", fetched_at=first.fetched_at, duration_ms=first.duration_ms, transport=first.transport, redirects=first.redirects) res.headers["x-companyatlas-pages"] = str(pages) return res def text_of(v: Any) -> str | None: if v is None: return None if isinstance(v, dict): for k in ("name", "label", "text", "title", "value"): if v.get(k): return normalize_whitespace(str(v[k])) return None if isinstance(v, list): return ", ".join(x for x in (text_of(i) for i in v) if x) or None return normalize_whitespace(str(v)) or None # ------------------------------------------------------------------------------------------------------------ ATS vendor map WORKDAY_RE = re.compile(r"https?://([a-z0-9-]+)\.(wd\d+)\.myworkdayjobs\.com/(?:([a-z]{2}-[A-Z]{2})/)?([A-Za-z0-9_-]+)", re.IGNORECASE) def ats_sensor_spec(vendor: str, token: str, board_url: str) -> tuple[str, str, dict[str, Any]] | None: """(api_url, connector_id, config) for a detected ATS board — the sensor URL *is* the public endpoint the board's page uses.""" v = vendor.lower() cfg: dict[str, Any] = {"vendor": v, "token": token, "board_url": board_url} if v == "greenhouse": return f"https://boards-api.greenhouse.io/v1/boards/{token}/jobs?content=false", "greenhouse-v1", cfg if v == "lever": region = "eu." if "jobs.eu.lever.co" in board_url else "" return f"https://api.{region}lever.co/v0/postings/{token}?mode=json", "lever-v1", cfg if v == "ashby": return f"https://api.ashbyhq.com/posting-api/job-board/{token}", "ashby-v1", cfg if v == "smartrecruiters": return f"https://api.smartrecruiters.com/v1/companies/{token}/postings?limit=100", "smartrecruiters-v1", cfg if v == "workable": return f"https://apply.workable.com/api/v1/widget/accounts/{token}", "workable-v1", cfg if v == "recruitee": return f"https://{token}.recruitee.com/api/offers/", "recruitee-v1", cfg if v == "personio": return f"https://{token}.jobs.personio.de/xml", "personio-v1", cfg if v == "teamtailor": host = urlparse(board_url).hostname or f"{token}.teamtailor.com" return f"https://{host}/jobs.json", "teamtailor-v1", cfg if v == "workday": m = WORKDAY_RE.search(board_url) if not m: return None tenant, wd, _locale, site = m.group(1), m.group(2), m.group(3), m.group(4) if site.lower() in ("wday", "en-us", "job"): return None cfg.update({"tenant": tenant, "wd": wd, "site": site}) return f"https://{tenant}.{wd}.myworkdayjobs.com/wday/cxs/{tenant}/{site}/jobs", "workday-v1", cfg return None __all__ = [ "CA_PROVINCES", "DATE_IN_URL_RE", "REMOTE_RE", "US_STATES", "WORKDAY_RE", "ats_sensor_spec", "country_code", "date_from_text", "date_from_url", "description_hash", "dig", "employment_type_norm", "finish_job", "is_engineering", "job_blocks", "job_fingerprint", "jobs_text", "load_json", "merged_result", "norm_name", "parse_date", "parse_location", "seniority_guess", "strip_html", "text_of", ]