# Trouve-KA — parseur HTML
# Author: Simon-Pierre Boucher
# Contact: contact@spboucher.ai
"""Extraction HTML avec selectolax (rapide, tolérant).
Extrait : titre, meta description, corps principal (boilerplate retiré),
headings, liens + anchors + nofollow, canonical, langue, dates de publication,
auteur, indices structurés (JSON-LD/OpenGraph) pour le scoring Québec.
"""
import json
import re
from datetime import datetime
from dateutil import parser as dateparser
from langdetect import DetectorFactory, LangDetectException, detect
from selectolax.parser import HTMLParser
from trouveka.shared import canonicalize_url
from trouveka.types import ExtractedLink, ParsedPage
DetectorFactory.seed = 42 # détection de langue déterministe
# Éléments retirés avant extraction du corps (navigation, pub, scripts…)
_STRIP_SELECTORS = (
"script", "style", "noscript", "template", "svg", "iframe", "form",
"nav", "header", "footer", "aside",
"[role=navigation]", "[role=banner]", "[role=contentinfo]", "[role=complementary]",
"[aria-hidden=true]", ".cookie-banner", "#cookie-banner", ".cookies", "[class*=cookie-consent]",
)
_MAIN_SELECTORS = ("main", "article", "[role=main]", "#main", "#content", ".main-content", ".content")
_WS = re.compile(r"\s+")
_CTRL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
def _clean(text: str | None) -> str:
if not text:
return ""
return _WS.sub(" ", _CTRL.sub(" ", text)).strip()
def _parse_date(value: str | None) -> datetime | None:
if not value:
return None
try:
dt = dateparser.parse(value)
except (ValueError, OverflowError, TypeError):
return None
if dt and dt.year >= 1990:
return dt
return None
def _meta(tree: HTMLParser, *names: str) -> str | None:
for name in names:
for attr in ("name", "property", "itemprop"):
node = tree.css_first(f'meta[{attr}="{name}"]')
if node:
content = node.attributes.get("content")
if content and content.strip():
return content.strip()
return None
def _extract_jsonld_hints(tree: HTMLParser, max_blocks: int = 10) -> tuple[list[str], datetime | None]:
"""Extrait des textes utiles des blocs JSON-LD (adresses, noms d'org) + datePublished."""
hints: list[str] = []
published: datetime | None = None
for node in tree.css('script[type="application/ld+json"]')[:max_blocks]:
raw = node.text()
if not raw or len(raw) > 100_000:
continue
try:
data = json.loads(raw)
except (json.JSONDecodeError, ValueError):
continue
stack = [data]
while stack:
item = stack.pop()
if isinstance(item, list):
stack.extend(item[:20])
elif isinstance(item, dict):
for key in ("name", "legalName", "addressLocality", "addressRegion",
"postalCode", "streetAddress", "telephone"):
val = item.get(key)
if isinstance(val, str) and len(val) < 200:
hints.append(val)
if published is None:
published = _parse_date(item.get("datePublished")) or _parse_date(item.get("dateCreated"))
stack.extend(v for v in item.values() if isinstance(v, (dict, list)))
if len(hints) > 60:
break
return hints[:60], published
_META_CHARSET_RE = re.compile(
rb']+charset=["\']?\s*([a-zA-Z0-9_-]{2,20})', re.IGNORECASE
)
_CHARSET_ALIASES = {"iso-8859-1": "cp1252", "latin-1": "cp1252", "latin1": "cp1252", "ansi": "cp1252"}
def decode_html(body: bytes, declared_charset: str | None = None) -> str:
"""Décode le HTML : charset HTTP déclaré → meta charset → UTF-8 strict → cp1252.
Les vieux sites québécois sont souvent en ISO-8859-1/cp1252; décoder
aveuglément en UTF-8 produit du charabia qui finirait dans l'index.
"""
candidates: list[str] = []
if declared_charset:
candidates.append(declared_charset.lower())
meta = _META_CHARSET_RE.search(body[:4096])
if meta:
candidates.append(meta.group(1).decode("ascii", errors="ignore").lower())
candidates.extend(["utf-8", "cp1252"])
for charset in candidates:
charset = _CHARSET_ALIASES.get(charset, charset)
try:
return body.decode(charset)
except (UnicodeDecodeError, LookupError):
continue
return body.decode("utf-8", errors="replace")
def looks_like_garbage(text: str, *, threshold: float = 0.03) -> bool:
"""True si le texte contient trop de caractères de remplacement/contrôle
(contenu binaire ou mal décodé — ne doit jamais être indexé)."""
if not text:
return False
sample = text[:20_000]
bad = sum(1 for c in sample if c == "�" or (ord(c) < 32 and c not in "\t\n\r"))
return bad / len(sample) > threshold
def parse_html(
url: str, html: bytes | str, *, max_links: int = 300, charset: str | None = None
) -> ParsedPage:
"""Parse une page HTML en ParsedPage. Ne lève pas sur du HTML dégueulasse."""
if isinstance(html, bytes):
html = decode_html(html, charset)
tree = HTMLParser(html)
# Directives robots de la page
robots_meta = (_meta(tree, "robots", "googlebot") or "").lower()
noindex = "noindex" in robots_meta
nofollow_page = "nofollow" in robots_meta
# Canonical
canonical = None
link_canonical = tree.css_first('link[rel="canonical"]')
if link_canonical:
canonical = canonicalize_url(link_canonical.attributes.get("href") or "", base=url)
# Titre / description
title = _clean(tree.css_first("title").text() if tree.css_first("title") else None)
og_title = _meta(tree, "og:title")
if not title and og_title:
title = _clean(og_title)
description = _clean(_meta(tree, "description", "og:description") or "")
# Langue déclarée
declared_lang = None
html_node = tree.css_first("html")
if html_node:
lang_attr = (html_node.attributes.get("lang") or "").strip().lower()
if lang_attr:
declared_lang = lang_attr[:2]
# Dates / auteur
published = _parse_date(_meta(tree, "article:published_time", "datePublished", "date", "dc.date"))
modified = _parse_date(_meta(tree, "article:modified_time", "dateModified"))
author = _meta(tree, "author", "article:author")
# Indices structurés (avant strip : JSON-LD est dans