"""Canonicalize HTML/CSS pairs so semantically equivalent inputs compare equal. Stripo serializes HTML attributes in non-deterministic order and varies inter-tag whitespace on each fetch, so byte comparison against the stored copy yields false positives. """ from __future__ import annotations import re from lxml import html _CSS_COMMENT_RE = re.compile(r"/\*.*?\*/", re.DOTALL) _CSS_WS_RE = re.compile(r"\s+") # `:` excluded — collapsing whitespace around it can change selector meaning # (`.foo :hover` vs `.foo:hover`). _CSS_AROUND_PUNCT_RE = re.compile(r"\s*([{};,])\s*") def canonicalize_css(css: str) -> str: s = _CSS_COMMENT_RE.sub("", css) s = _CSS_WS_RE.sub(" ", s) s = _CSS_AROUND_PUNCT_RE.sub(r"\1", s) return s.strip() def _normalize_style_attr(value: str) -> str: decls: list[str] = [] for raw in value.split(";"): d = _CSS_WS_RE.sub(" ", raw).strip() if not d: continue if ":" in d: k, _, v = d.partition(":") d = f"{k.strip()}:{v.strip()}" decls.append(d) return ";".join(sorted(decls)) def _normalize_text(el: html.HtmlElement) -> None: if el.tag == "style" and isinstance(el.text, str): el.text = canonicalize_css(el.text) elif isinstance(el.text, str): el.text = el.text.strip() or None def _normalize_attrs(el: html.HtmlElement) -> None: attrs = sorted(el.items()) for k in list(el.attrib): del el.attrib[k] for k, v in attrs: if k == "style": v = _normalize_style_attr(v) elif k == "class": v = " ".join(sorted(v.split())) el.set(k, v) def canonicalize_html(source: str) -> str: if not source.strip(): return "" root = html.fromstring(source) for el in root.iter(): if isinstance(el.tail, str): el.tail = el.tail.strip() or None if not isinstance(el.tag, str): continue _normalize_text(el) if el.attrib: _normalize_attrs(el) return html.tostring(root, method="html", encoding="unicode") def canonicalize(html_content: str | None, css_content: str | None) -> tuple[str, str]: return canonicalize_html(html_content or ""), canonicalize_css(css_content or "")