spb/trouve-ka Public
Trouve-KA — moteur de recherche web indépendant, Québec-first. Crawler distribué, index OpenSearch, ranking bilingue, galerie d'images. En prod : www.trouve-ka.com
Python 76.8%
TypeScript 15.7%
SQL 3.9%
Shell 1.4%
CSS 1.3%
Dockerfile 0.7%
1# Trouve-KA — parseur HTML2# Author: Simon-Pierre Boucher3# Contact: contact@spboucher.ai45"""Extraction HTML avec selectolax (rapide, tolérant).67Extrait : titre, meta description, corps principal (boilerplate retiré),8headings, liens + anchors + nofollow, canonical, langue, dates de publication,9auteur, indices structurés (JSON-LD/OpenGraph) pour le scoring Québec.10"""1112import json13import re14from datetime import datetime1516from dateutil import parser as dateparser17from langdetect import DetectorFactory, LangDetectException, detect18from selectolax.parser import HTMLParser1920from trouveka.shared import canonicalize_url21from trouveka.types import ExtractedLink, ParsedPage2223DetectorFactory.seed = 42 # détection de langue déterministe2425# Éléments retirés avant extraction du corps (navigation, pub, scripts…)26_STRIP_SELECTORS = (27 "script", "style", "noscript", "template", "svg", "iframe", "form",28 "nav", "header", "footer", "aside",29 "[role=navigation]", "[role=banner]", "[role=contentinfo]", "[role=complementary]",30 "[aria-hidden=true]", ".cookie-banner", "#cookie-banner", ".cookies", "[class*=cookie-consent]",31)3233_MAIN_SELECTORS = ("main", "article", "[role=main]", "#main", "#content", ".main-content", ".content")3435_WS = re.compile(r"\s+")36_CTRL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")373839def _clean(text: str | None) -> str:40 if not text:41 return ""42 return _WS.sub(" ", _CTRL.sub(" ", text)).strip()434445def _parse_date(value: str | None) -> datetime | None:46 if not value:47 return None48 try:49 dt = dateparser.parse(value)50 except (ValueError, OverflowError, TypeError):51 return None52 if dt and dt.year >= 1990:53 return dt54 return None555657def _meta(tree: HTMLParser, *names: str) -> str | None:58 for name in names:59 for attr in ("name", "property", "itemprop"):60 node = tree.css_first(f'meta[{attr}="{name}"]')61 if node:62 content = node.attributes.get("content")63 if content and content.strip():64 return content.strip()65 return None666768def _extract_jsonld_hints(tree: HTMLParser, max_blocks: int = 10) -> tuple[list[str], datetime | None]:69 """Extrait des textes utiles des blocs JSON-LD (adresses, noms d'org) + datePublished."""70 hints: list[str] = []71 published: datetime | None = None72 for node in tree.css('script[type="application/ld+json"]')[:max_blocks]:73 raw = node.text()74 if not raw or len(raw) > 100_000:75 continue76 try:77 data = json.loads(raw)78 except (json.JSONDecodeError, ValueError):79 continue80 stack = [data]81 while stack:82 item = stack.pop()83 if isinstance(item, list):84 stack.extend(item[:20])85 elif isinstance(item, dict):86 for key in ("name", "legalName", "addressLocality", "addressRegion",87 "postalCode", "streetAddress", "telephone"):88 val = item.get(key)89 if isinstance(val, str) and len(val) < 200:90 hints.append(val)91 if published is None:92 published = _parse_date(item.get("datePublished")) or _parse_date(item.get("dateCreated"))93 stack.extend(v for v in item.values() if isinstance(v, (dict, list)))94 if len(hints) > 60:95 break96 return hints[:60], published979899_META_CHARSET_RE = re.compile(100 rb'<meta[^>]+charset=["\']?\s*([a-zA-Z0-9_-]{2,20})', re.IGNORECASE101)102_CHARSET_ALIASES = {"iso-8859-1": "cp1252", "latin-1": "cp1252", "latin1": "cp1252", "ansi": "cp1252"}103104105def decode_html(body: bytes, declared_charset: str | None = None) -> str:106 """Décode le HTML : charset HTTP déclaré → meta charset → UTF-8 strict → cp1252.107108 Les vieux sites québécois sont souvent en ISO-8859-1/cp1252; décoder109 aveuglément en UTF-8 produit du charabia qui finirait dans l'index.110 """111 candidates: list[str] = []112 if declared_charset:113 candidates.append(declared_charset.lower())114 meta = _META_CHARSET_RE.search(body[:4096])115 if meta:116 candidates.append(meta.group(1).decode("ascii", errors="ignore").lower())117 candidates.extend(["utf-8", "cp1252"])118 for charset in candidates:119 charset = _CHARSET_ALIASES.get(charset, charset)120 try:121 return body.decode(charset)122 except (UnicodeDecodeError, LookupError):123 continue124 return body.decode("utf-8", errors="replace")125126127def looks_like_garbage(text: str, *, threshold: float = 0.03) -> bool:128 """True si le texte contient trop de caractères de remplacement/contrôle129 (contenu binaire ou mal décodé — ne doit jamais être indexé)."""130 if not text:131 return False132 sample = text[:20_000]133 bad = sum(1 for c in sample if c == "�" or (ord(c) < 32 and c not in "\t\n\r"))134 return bad / len(sample) > threshold135136137def parse_html(138 url: str, html: bytes | str, *, max_links: int = 300, charset: str | None = None139) -> ParsedPage:140 """Parse une page HTML en ParsedPage. Ne lève pas sur du HTML dégueulasse."""141 if isinstance(html, bytes):142 html = decode_html(html, charset)143 tree = HTMLParser(html)144145 # Directives robots de la page146 robots_meta = (_meta(tree, "robots", "googlebot") or "").lower()147 noindex = "noindex" in robots_meta148 nofollow_page = "nofollow" in robots_meta149150 # Canonical151 canonical = None152 link_canonical = tree.css_first('link[rel="canonical"]')153 if link_canonical:154 canonical = canonicalize_url(link_canonical.attributes.get("href") or "", base=url)155156 # Titre / description157 title = _clean(tree.css_first("title").text() if tree.css_first("title") else None)158 og_title = _meta(tree, "og:title")159 if not title and og_title:160 title = _clean(og_title)161 description = _clean(_meta(tree, "description", "og:description") or "")162163 # Langue déclarée164 declared_lang = None165 html_node = tree.css_first("html")166 if html_node:167 lang_attr = (html_node.attributes.get("lang") or "").strip().lower()168 if lang_attr:169 declared_lang = lang_attr[:2]170171 # Dates / auteur172 published = _parse_date(_meta(tree, "article:published_time", "datePublished", "date", "dc.date"))173 modified = _parse_date(_meta(tree, "article:modified_time", "dateModified"))174 author = _meta(tree, "author", "article:author")175176 # Indices structurés (avant strip : JSON-LD est dans <script>)177 structured_hints, jsonld_published = _extract_jsonld_hints(tree)178 published = published or jsonld_published179180 # Liens (avant strip : les liens de nav comptent pour la découverte)181 links: list[ExtractedLink] = []182 seen: set[str] = set()183 for node in tree.css("a[href]"):184 if len(links) >= max_links:185 break186 href = node.attributes.get("href") or ""187 if href.startswith(("#", "javascript:", "mailto:", "tel:", "data:")):188 continue189 normalized = canonicalize_url(href, base=url)190 if not normalized or normalized in seen:191 continue192 seen.add(normalized)193 rel = (node.attributes.get("rel") or "").lower()194 links.append(195 ExtractedLink(196 url=normalized,197 anchor=_clean(node.text())[:200],198 nofollow="nofollow" in rel or nofollow_page,199 )200 )201202 # Headings203 headings = [_clean(n.text()) for n in tree.css("h1, h2, h3")[:40]]204 headings = [h for h in headings if h]205206 # Corps principal : strip du boilerplate, puis zone principale si identifiable207 for selector in _STRIP_SELECTORS:208 for node in tree.css(selector):209 node.decompose()210 body_node = None211 for selector in _MAIN_SELECTORS:212 body_node = tree.css_first(selector)213 if body_node:214 break215 if body_node is None:216 body_node = tree.css_first("body") or tree.root217 body = _clean(body_node.text(separator=" ", deep=True))[:200_000]218219 # Langue : déclaration HTML validée/complétée par détection statistique220 language = declared_lang if declared_lang in ("fr", "en") else None221 sample = body[:4000] or title222 if sample and len(sample) > 40:223 try:224 detected = detect(sample)225 if detected in ("fr", "en"):226 # La détection l'emporte si elle contredit une déclaration douteuse227 language = detected if language is None or detected != language else language228 except LangDetectException:229 pass230231 return ParsedPage(232 url=url,233 canonical_url=canonical,234 title=title[:500],235 description=description[:1000],236 body=body,237 headings=headings,238 language=language,239 links=links,240 published_at=published,241 modified_at=modified,242 author=_clean(author)[:200] if author else None,243 noindex=noindex,244 nofollow_page=nofollow_page,245 structured_hints=structured_hints,246 )247