"""Generic HTML connector (spec §9.1, §107): one connector for every corporate web surface. `sdk.normalize` supplies text, blocks, metadata, JSON-LD and links; this module adds *surface-aware* typed extraction: leadership → people (role category, is_executive) pricing → plans (currency, period, unit, contact-sales, features) products/services/solutions → product cards locations → offices/stores (city / country only when stated) newsroom/blog/press/changelog/research/IR → news items careers → job listings (list / table / card patterns with job-like anchors) legal/docs/homepage/about/… → text + blocks only Structured data (JSON-LD / microdata) is used first, DOM heuristics second, and nothing is ever invented: no address, no country, no date that the page does not state. Every surface also yields `discovered` URLs (classified links) for the discovery feedback loop. Precision rules (`connectors/_precision.py`) reject navigation / CTA / cookie-consent / marketing noise at the point of extraction. """ from __future__ import annotations import re from collections.abc import Mapping from typing import Any from urllib.parse import urlparse from selectolax.lexbor import LexborHTMLParser from companyatlas.config import settings from companyatlas.connectors._precision import ( STREET_RE, is_known_city, looks_like_person_name, looks_like_role_title, news_verdict, normalize_location, normalize_person, plan_name_ok, plan_verdict, price_text_from, product_verdict, refine_job, role_category, states_job_location, ) from companyatlas.connectors._util import ( country_code, date_from_text, date_from_url, finish_job, job_blocks, norm_name, parse_date, parse_location, text_of, ) from companyatlas.connectors.jsonld_jobs import jobs_from_jsonld from companyatlas.fetch import FetchResult from companyatlas.sdk import normalize from companyatlas.sdk.connector import Connector, ConnectorMeta, register from companyatlas.sdk.models import ( Block, DiscoveredUrl, ExtractedJob, ExtractedLocation, ExtractedNewsItem, ExtractedPerson, ExtractedPlan, ExtractedProduct, Extraction, ) from companyatlas.sdk.normalize import NormalizedPage, normalize_whitespace from companyatlas.taxonomy import FetchMode, Surface from companyatlas.urls import absolutize, canonicalize_url, classify_url, is_static_asset, looks_like_trap, registrable_domain MAX_PEOPLE, MAX_PLANS, MAX_LOCATIONS, MAX_NEWS, MAX_JOBS, MAX_PRODUCTS, MAX_DISCOVERED = 200, 16, 300, 80, 300, 120, 120 NEWS_SURFACES = {Surface.NEWSROOM, Surface.BLOG, Surface.CHANGELOG, Surface.RESEARCH, Surface.INVESTOR_RELATIONS} NEWS_CATEGORY = {Surface.NEWSROOM: "press", Surface.BLOG: "blog", Surface.CHANGELOG: "changelog", Surface.RESEARCH: "research", Surface.INVESTOR_RELATIONS: "ir"} PRODUCT_SURFACES = {Surface.PRODUCTS, Surface.SERVICES, Surface.SOLUTIONS} # ------------------------------------------------------------------------------------------------------------ people # Role vocabulary, name shape and title cleaning live in `_precision` (shared with the pipeline); re-exported here for callers/tests. looks_like_name = looks_like_person_name looks_like_title = looks_like_role_title PERSON_CARD_SCAN_LINES = 4 # a person card states the name within its first lines PERSON_TITLE_WINDOW = 3 # …and the title within the next few def _card_person(lines: list[str]) -> tuple[str, str | None] | None: """(name, title) from a card's lines: the title may precede the name (swapped cards) or follow it; role-like lines win over prose.""" name_idx = next((i for i, ln in enumerate(lines[:PERSON_CARD_SCAN_LINES]) if looks_like_name(ln)), None) if name_idx is None: return None before = [x for x in lines[:name_idx] if looks_like_title(x)] after = [x for x in lines[name_idx + 1:name_idx + 1 + PERSON_TITLE_WINDOW] if not looks_like_name(x)] title = (before[-1] if before else None) or next((x for x in after if looks_like_title(x)), None) or (after[0] if after else None) return lines[name_idx], title def extract_people(page: NormalizedPage) -> list[ExtractedPerson]: out: list[ExtractedPerson] = [] seen: set[str] = set() def add(name: str, title: str | None, url: str | None = None) -> None: fixed = normalize_person(name, title) if fixed is None: return name, title = fixed key = norm_name(name) if not key or key in seen or len(out) >= MAX_PEOPLE: return seen.add(key) cat, is_exec = role_category(title) out.append(ExtractedPerson(name=name[:120], title=(title[:160] if title else None), role_category=cat, is_executive=is_exec, url=url)) for p in page.jsonld.get("persons", []) + page.microdata.get("persons", []): name = text_of(p.get("name")) if name: add(name, text_of(p.get("jobTitle")) or text_of(p.get("title")), text_of(p.get("url")) if isinstance(p.get("url"), str) else None) for b in page.blocks: if b.kind != "person": continue lines = [ln.strip() for ln in b.text.split("\n") if ln.strip()] found = _card_person(lines) if found is not None: add(found[0], found[1], b.attrs.get("href")) if len(out) < 2: # fallback: heading = name, next short block = title blocks = page.blocks for i, b in enumerate(blocks): if b.kind == "heading" and int(b.attrs.get("level", 3)) >= 2 and looks_like_name(b.text): nxt = next((x for x in blocks[i + 1:i + 3] if x.kind in ("paragraph", "other", "list", "section")), None) if nxt is not None and looks_like_title(nxt.text.split("\n")[0]): add(b.text, nxt.text.split("\n")[0]) return out # ------------------------------------------------------------------------------------------------------------ pricing CURRENCY_SYMBOLS = {"$": "USD", "US$": "USD", "USD": "USD", "€": "EUR", "EUR": "EUR", "£": "GBP", "GBP": "GBP", "¥": "JPY", "JPY": "JPY", "₹": "INR", "INR": "INR", "C$": "CAD", "CA$": "CAD", "CAD": "CAD", "A$": "AUD", "AUD": "AUD", "CHF": "CHF", "SEK": "SEK", "NOK": "NOK", "DKK": "DKK", "kr": "SEK", "zł": "PLN", "PLN": "PLN", "R$": "BRL", "BRL": "BRL", "MX$": "MXN", "₩": "KRW", "SGD": "SGD", "S$": "SGD", "HK$": "HKD", "NZ$": "NZD", "₺": "TRY", "元": "CNY", "CNY": "CNY", "RMB": "CNY"} PRICE_RE = re.compile(r"(?:(?PUS\$|CA\$|C\$|A\$|NZ\$|HK\$|S\$|MX\$|R\$|USD|EUR|GBP|CAD|AUD|CHF|JPY|INR|SEK|NOK|DKK|PLN|BRL|CNY|RMB|SGD|[$€£¥₹₩₺])\s?" r"(?P\d{1,3}(?:[,.\s]\d{3})*(?:[.,]\d{1,2})?|\d+(?:[.,]\d{1,2})?))|" r"(?:(?P\d{1,3}(?:[,.\s]\d{3})*(?:[.,]\d{1,2})?|\d+(?:[.,]\d{1,2})?)\s?(?P€|£|USD|EUR|GBP|CHF|kr|zł|元|\$))") PERIOD_RE = re.compile(r"(?:(?:per|/|a|each|every)\s*(?Pmonth|mo|year|yr|annum|week|wk|day|hour|hr|user|seat|member|license|licence|agent|editor|" r"contact|1,?000|1k|GB|TB|request|transaction|call|minute|mois|an|année|monat|jahr)\b)|(?Pmonthly|annually|yearly|per annum|" r"billed (?:monthly|annually|yearly)|mensuel|annuel|monatlich|jährlich|one[- ]time|lifetime)", re.IGNORECASE) CONTACT_RE = re.compile(r"\b(contact (us|sales)|talk to (us|sales|an expert)|custom pricing|custom quote|get a quote|request (a )?(quote|demo)|let'?s talk|" r"on request|upon request|sur devis|nous contacter|contactez-nous|auf anfrage|individuell|tailored|bespoke|call us)\b", re.IGNORECASE) FREE_RE = re.compile(r"^(free|gratuit|kostenlos|gratis|\$\s?0(\.00)?|0\s?€|€\s?0)\b", re.IGNORECASE) FROM_RE = re.compile(r"\b(from|starting at|starts at|as low as|à partir de|ab)\b", re.IGNORECASE) MONTH_UNITS = {"month", "mo", "monthly", "mensuel", "monatlich", "mois", "monat"} YEAR_UNITS = {"year", "yr", "annum", "annually", "yearly", "per annum", "annuel", "jährlich", "an", "année", "jahr"} SEAT_UNITS = {"user", "seat", "member", "license", "licence", "agent", "editor", "contact"} USAGE_UNITS = {"1,000", "1000", "1k", "gb", "tb", "request", "transaction", "call", "minute", "hour", "hr", "day", "week", "wk"} def parse_price(text: str) -> dict[str, Any] | None: """→ {price, currency, billing_period, unit, price_text, contact_sales} or None when the text states no price.""" t = normalize_whitespace(text) if CONTACT_RE.search(t) and not PRICE_RE.search(t): return {"price": None, "currency": None, "billing_period": "contact", "unit": None, "price_text": price_text_from(t) or t[:80], "contact_sales": True} m = PRICE_RE.search(t) if m is None: if FREE_RE.match(t): return {"price": 0.0, "currency": None, "billing_period": None, "unit": None, "price_text": price_text_from(t) or t[:80], "contact_sales": False} return None cur = m.group("cur") or m.group("cur2") amt = m.group("amt") or m.group("amt2") amt_clean = amt.replace(" ", "") if re.fullmatch(r"\d{1,3}(,\d{3})+(\.\d{1,2})?", amt_clean): amt_clean = amt_clean.replace(",", "") elif re.fullmatch(r"\d{1,3}(\.\d{3})+(,\d{1,2})?", amt_clean): amt_clean = amt_clean.replace(".", "").replace(",", ".") elif re.fullmatch(r"\d+,\d{1,2}", amt_clean): amt_clean = amt_clean.replace(",", ".") else: amt_clean = amt_clean.replace(",", "") try: price = float(amt_clean) except ValueError: return None tail = t[m.end():m.end() + 60] pm = PERIOD_RE.search(tail) or PERIOD_RE.search(t) period, unit = None, None if pm: u = (pm.group("unit") or pm.group("word") or "").lower() if u in MONTH_UNITS or u.startswith("billed monthly"): period = "month" elif u in YEAR_UNITS or "annual" in u or "yearly" in u: period = "year" elif u in ("one-time", "one time", "lifetime"): period = "one_time" elif u in SEAT_UNITS: unit = u elif u in USAGE_UNITS: period, unit = "usage", u # a second unit ("per user per month") for pm2 in PERIOD_RE.finditer(tail): u2 = (pm2.group("unit") or pm2.group("word") or "").lower() if u2 in SEAT_UNITS and not unit: unit = u2 elif (u2 in MONTH_UNITS) and not period: period = "month" elif (u2 in YEAR_UNITS) and not period: period = "year" return {"price": price, "currency": CURRENCY_SYMBOLS.get(cur, cur.upper() if cur and cur.isalpha() else None), "billing_period": period, "unit": unit, "price_text": price_text_from(t) or t[m.start():m.end()].strip()[:80], "contact_sales": bool(CONTACT_RE.search(t))} PLAN_NAME_SCAN_LINES = 3 # the plan name is one of the first lines before the price (eyebrows / marketing headings are skipped) def _plan_name_from(lines: list[str], path: str) -> tuple[str | None, list[str]]: """(plan name, remaining lines): the first valid short label before the price line (eyebrows such as "Most popular" are skipped). Otherwise the heading just above the card (its path tail) names the tier — unless that heading is generic ("Plans", "Pricing") or a marketing sentence, in which case the card yields no plan.""" for i, ln in enumerate(lines[:PLAN_NAME_SCAN_LINES]): if plan_name_ok(ln): # "Free" is a valid tier name even though it also reads as a price return ln, lines[i + 1:] if PRICE_RE.search(ln) or parse_price(ln) is not None: break tail = path.split(" > ")[-1] if path else "" return (tail if plan_name_ok(tail) else None), lines def _plan_from_lines(name: str | None, lines: list[str]) -> ExtractedPlan | None: if not name: return None price_info = None features: list[str] = [] for ln in lines: if price_info is None: info = parse_price(ln) if info is not None: price_info = info continue if 2 <= len(ln) <= 140 and not parse_price(ln) and len(features) < 25: features.append(ln) if price_info is None and FREE_RE.match(name): price_info = parse_price(name) # a tier literally called "Free" with no separate price line if price_info is None: return None return ExtractedPlan(plan_name=name[:80], price=price_info["price"], price_text=price_info["price_text"], currency=price_info["currency"], billing_period=price_info["billing_period"], unit=price_info["unit"], features=features, contact_sales=price_info["contact_sales"]) def extract_plans(page: NormalizedPage) -> list[ExtractedPlan]: out: list[ExtractedPlan] = [] seen: set[str] = set() def add(plan: ExtractedPlan | None) -> None: if plan is None or not plan_verdict(plan).ok: return key = norm_name(plan.plan_name) if not key or key in seen or len(out) >= MAX_PLANS: return seen.add(key) out.append(plan) for b in page.blocks: if b.kind != "pricing_plan": continue lines = [ln.strip() for ln in b.text.split("\n") if ln.strip()] if not lines: continue name, rest = _plan_name_from(lines, b.path) add(_plan_from_lines(name, rest)) if not out: # fallback: heading followed by a price before the next heading (common in hand-rolled pricing tables) blocks = page.blocks i = 0 while i < len(blocks): b = blocks[i] if b.kind == "heading" and int(b.attrs.get("level", 2)) >= 2 and plan_name_ok(b.text): j = i + 1 lines: list[str] = [] while j < len(blocks) and blocks[j].kind != "heading" and j - i <= 10: lines.extend(x.strip() for x in blocks[j].text.split("\n") if x.strip()) j += 1 add(_plan_from_lines(b.text, lines)) i = j continue i += 1 if not out: # tables: first cell = plan, another cell = price for b in page.blocks: if b.kind == "table" and "|" in b.text: cells = [c.strip() for c in b.text.split("|")] if len(cells) >= 2 and parse_price(cells[0]) is None and any(parse_price(c) for c in cells[1:]): add(_plan_from_lines(cells[0], cells[1:])) return out # ------------------------------------------------------------------------------------------------------------ locations LOCATION_KIND_RULES: list[tuple[str, re.Pattern[str]]] = [ ("headquarters", re.compile(r"\b(headquarters|head office|hq|global office|corporate office|siège|hauptsitz|sede central)\b", re.IGNORECASE)), ("factory", re.compile(r"\b(factory|plant|manufacturing|production site|mill|foundry|usine|werk|fabrik)\b", re.IGNORECASE)), ("warehouse", re.compile(r"\b(warehouse|distribution cent(er|re)|fulfil?lment|logistics hub|entrepôt|lager)\b", re.IGNORECASE)), ("lab", re.compile(r"\b(lab|laboratory|research cent(er|re)|r&d|innovation cent(er|re))\b", re.IGNORECASE)), ("data_center", re.compile(r"\b(data ?cent(er|re)|datacenter|server farm)\b", re.IGNORECASE)), ("store", re.compile(r"\b(store|shop|boutique|showroom|outlet|dealer(ship)?|branch|agence|filiale)\b", re.IGNORECASE)), ] CITY_COUNTRY_RE = re.compile(r"^([A-ZÀ-Ý][\w'’.\- ]{1,40}),\s*([A-Za-zÀ-ÿ .]{2,40})$") MAX_VENUE_LINE_CHARS = 60 # "Hormuz Grand Hotel" — a venue line under a country heading def _location_from_lines(name: str, lines: list[str], href: str | None = None) -> ExtractedLocation | None: text = " \n ".join(lines) kind = next((k for k, pat in LOCATION_KIND_RULES if pat.search(name) or pat.search(text[:200])), "office") city = region = country = address = None for ln in lines: street = STREET_RE.search(ln) if street and address is None and len(ln) <= 120: address = ln probe = (ln[:street.start()] + " " + ln[street.end():]).strip(" ,") if street else ln if country is None and probe: loc = parse_location(probe) if loc["country"]: # the line that states the country is the authoritative "City, Country" line city, region, country = loc["city"] or city, loc["region"], loc["country"] elif loc["region"]: city, region = city or loc["city"], loc["region"] if country is None: c = country_code(name) if c: country = c else: m = CITY_COUNTRY_RE.match(name) if m and country_code(m.group(2)): city, country = m.group(1).strip(), country_code(m.group(2)) if country_code(name) and city is None: # heading is a country ("Oman") — the first short line that is neither an address nor a place is the venue / office name venue = next((ln for ln in lines if ln != address and len(ln) <= MAX_VENUE_LINE_CHARS and not parse_location(ln)["country"] and not country_code(ln) and not any(ch.isdigit() for ch in ln)), None) if venue: name = venue elif is_known_city(name): city = name # city-states: Singapore, Monaco, Hong Kong elif city is None and country is not None and looks_like_city(name): city = name if city is None and country is None and address is None and kind == "office": return None return ExtractedLocation(name=name[:120], kind=kind, city=city, region=region, country=country, address_text=address) def looks_like_city(text: str) -> bool: t = normalize_whitespace(text) return 2 <= len(t) <= 40 and not any(ch.isdigit() for ch in t) and t[0].isupper() and not LOCATION_KIND_RULES[0][1].search(t) def extract_locations(page: NormalizedPage) -> list[ExtractedLocation]: out: list[ExtractedLocation] = [] seen: set[str] = set() def add(loc: ExtractedLocation | None) -> None: loc = normalize_location(loc) if loc is not None else None if loc is None: return key = norm_name(loc.name) if not key or key in seen or len(out) >= MAX_LOCATIONS: return seen.add(key) out.append(loc) for a in page.jsonld.get("addresses", []) + page.jsonld.get("places", []) + page.microdata.get("addresses", []): addr = a.get("address") if isinstance(a.get("address"), dict) else a city = text_of(addr.get("addressLocality")) if isinstance(addr, dict) else None country = country_code(text_of(addr.get("addressCountry"))) if isinstance(addr, dict) else None name = text_of(a.get("name")) or ", ".join(x for x in (city, country) if x) if name and (city or country): street = text_of(addr.get("streetAddress")) if isinstance(addr, dict) else None add(ExtractedLocation(name=name[:120], kind="office", city=city, region=text_of(addr.get("addressRegion")) if isinstance(addr, dict) else None, country=country, address_text=street)) for b in page.blocks: if b.kind != "location": continue lines = [ln.strip() for ln in b.text.split("\n") if ln.strip()] if lines: add(_location_from_lines(lines[0], lines[1:], b.attrs.get("href"))) if len(out) < 2: # fallback: headings naming a city/country followed by address-like lines blocks = page.blocks for i, b in enumerate(blocks): if b.kind == "heading" and int(b.attrs.get("level", 3)) >= 2 and (looks_like_city(b.text) or country_code(b.text)): lines: list[str] = [] for nb in blocks[i + 1:i + 4]: if nb.kind == "heading": break lines.extend(x.strip() for x in nb.text.split("\n") if x.strip()) add(_location_from_lines(b.text, lines)) return out # ------------------------------------------------------------------------------------------------------------ news NEWS_NOISE_ANCHOR = re.compile(r"^(read more|learn more|more|continue reading|view all|see all|all news|all posts|next|previous|older|newer|\d+)$", re.IGNORECASE) MIN_NEWS_TITLE_CHARS = 3 # anything shorter is a glyph or a counter; the precision rule decides the rest (words / date) def _time_index(html: str, base_url: str) -> dict[str, Any]: """canonical href → datetime for