"""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), "