"""HTML → semantic building blocks (spec §17–19, §107). html ──parse()──▶ NormalizedPage(title, meta, lang, blocks, text, links, jsonld, microdata) ──to_extraction()──▶ Extraction Two layers are kept deliberately separate: * **stored text** — whitespace-normalised, but otherwise the original (what a human reads in the historical viewer); * **hash / diff layer** — `normalized_text()` replaces classic noise (dates, times, "3 minutes ago", counters, csrf/nonce/session tokens, cache-busting query params) with stable tokens so that `text_hash`, block hashes and simhashes ignore it. Blocks carry a stable `key` = kind + heading path + simhash bucket (+ occurrence index) — never DOM position — so a reordered section is a *move*, not an add/remove pair. Deterministic; no network; no LLM. """ from __future__ import annotations import hashlib import html as html_lib import json import logging import re from collections import Counter from dataclasses import dataclass, field from typing import Any from selectolax.lexbor import LexborHTMLParser, LexborNode from companyatlas.sdk.models import Block, Extraction log = logging.getLogger(__name__) NORMALIZE_VERSION = "normalize-v1" # ------------------------------------------------------------------------------------------------------------ element sets DROP_TAGS = frozenset({"script", "style", "noscript", "svg", "iframe", "template", "canvas", "video", "audio", "source", "track", "object", "embed", "map", "area", "input", "select", "textarea", "option", "optgroup", "datalist", "meter", "progress", "link", "meta", "base", "head", "picture", "img", "dialog", "math"}) BLOCK_TAGS = frozenset({"address", "article", "aside", "blockquote", "body", "dd", "details", "div", "dl", "dt", "fieldset", "figcaption", "figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "header", "hr", "li", "main", "nav", "ol", "p", "pre", "section", "summary", "table", "tbody", "td", "tfoot", "th", "thead", "tr", "ul", "caption", "legend", "menu"}) HEADING_TAGS = ("h1", "h2", "h3", "h4", "h5", "h6") COOKIE_RE = re.compile(r"(cookie|consent|gdpr|onetrust|cookiebot|truste|didomi|usercentrics|osano|cc-banner|cc-window|privacy-banner|" r"cmp-container|qc-cmp|sp_message|termly|iubenda|klaro|cookieyes|axeptio|tarteaucitron)", re.IGNORECASE) NAV_CLASS_RE = re.compile(r"(^|[\s_-])(nav|navbar|navigation|menu|breadcrumbs?|topbar|masthead|site-header|global-header)([\s_-]|$)", re.IGNORECASE) FOOTER_CLASS_RE = re.compile(r"(^|[\s_-])(footer|site-footer|global-footer|colophon|legal-links)([\s_-]|$)", re.IGNORECASE) HERO_CLASS_RE = re.compile(r"(^|[\s_-])(hero|jumbotron|banner|masthead|splash|intro|cover|landing-hero)([\s_-]|$)", re.IGNORECASE) CARD_PATTERNS: list[tuple[str, re.Pattern[str]]] = [ ("pricing_plan", re.compile(r"(^|[\s_-])(pricing[-_ ]?(plan|card|tier|column|table|box|option)|plan[-_ ]?(card|box|column|tier|item)|tier[-_ ]?card|" r"price[-_ ]?(card|box|column|plan)|package[-_ ]?(card|box))([\s_-]|$)", re.IGNORECASE)), ("job_listing", re.compile(r"(^|[\s_-])(job|jobs|opening|position|vacancy|posting|role|career)[-_ ]?(item|card|listing|row|link|entry|tile|post)?([\s_-]|$)", re.IGNORECASE)), ("person", re.compile(r"(^|[\s_-])(team[-_ ]?member|member[-_ ]?card|person|people[-_ ]?card|bio|executive|leader|profile[-_ ]?card|staff|founder|" r"board[-_ ]?member|management[-_ ]?member|employee[-_ ]?card|director)([\s_-]|$)", re.IGNORECASE)), ("location", re.compile(r"(^|[\s_-])(office|location|store|branch|address|showroom|site[-_ ]?card|headquarters|hq)([\s_-]|$)", re.IGNORECASE)), ("news_item", re.compile(r"(^|[\s_-])(news|press|article|post|release|story|blog|announcement|update|publication)[-_ ]?(item|card|teaser|tile|preview|summary|link|list-item|entry)?([\s_-]|$)", re.IGNORECASE)), ("product_card", re.compile(r"(^|[\s_-])((product|products|catalog|sku|offering|solution)[-_ ]?(card|tile|item|box|grid-item|teaser|entry)?|item[-_ ]?card|grid[-_ ]item)([\s_-]|$)", re.IGNORECASE)), ("faq", re.compile(r"(^|[\s_-])(faq|accordion|question|collapsible)([\s_-]|$)", re.IGNORECASE)), ] CARD_MAX_TEXT = 1800 # Weights per block kind (spec §19–20): what matters for significance. Nav/footer/cookie are kept for discovery, not for change value. BLOCK_WEIGHTS: dict[str, float] = { "hero": 1.5, "pricing_plan": 1.6, "job_listing": 1.4, "person": 1.4, "product_card": 1.3, "location": 1.2, "news_item": 1.2, "heading": 1.0, "paragraph": 1.0, "section": 1.0, "table": 1.1, "list": 0.9, "faq": 0.8, "code": 0.7, "quote": 0.6, "other": 0.5, "header": 0.3, "nav": 0.2, "footer": 0.15, } LOW_VALUE_KINDS = frozenset({"nav", "footer", "header"}) # ------------------------------------------------------------------------------------------------------------ noise normalisation _MONTH = r"(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|june?|july?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?|" \ r"janvier|février|fevrier|mars|avril|mai|juin|juillet|août|aout|septembre|octobre|novembre|décembre|decembre|" \ r"januar|februar|märz|maerz|april|juni|juli|august|oktober|dezember|enero|febrero|marzo|abril|mayo|junio|julio|agosto|septiembre|octubre|noviembre|diciembre)" NOISE_RULES: list[tuple[re.Pattern[str], str]] = [ (re.compile(r"\b\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?\b"), ""), (re.compile(r"\b(?:\d{1,2}(?:st|nd|rd|th)?\s+)?" + _MONTH + r"\.?\s+\d{1,2}(?:st|nd|rd|th)?,?\s+\d{4}\b", re.IGNORECASE), ""), (re.compile(r"\b\d{1,2}(?:st|nd|rd|th)?\s+" + _MONTH + r"\.?,?\s+\d{4}\b", re.IGNORECASE), ""), (re.compile(r"\b" + _MONTH + r"\.?\s+\d{4}\b", re.IGNORECASE), ""), (re.compile(r"\b\d{1,2}[/.-]\d{1,2}[/.-]\d{2,4}\b"), ""), (re.compile(r"\b\d{1,2}:\d{2}(?::\d{2})?\s*(?:am|pm|a\.m\.|p\.m\.|utc|gmt|est|pst|cet|z)?\b", re.IGNORECASE), "