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 — scoreur Québec2# Author: Simon-Pierre Boucher3# Contact: contact@spboucher.ai45"""Calcul de page_quebec_score ∈ [0, 1].67Combinaison de signaux hétérogènes (CLAUDE.md §7) : TLD, toponymes, codes8postaux QC, indicatifs téléphoniques, organisations connues, langue française,9mentions structurées de la province. Aucun signal seul ne suffit (un .ca seul10ne prouve rien); le score sature progressivement via une somme amortie.11"""1213import re1415from trouveka.types import ParsedPage, QuebecSignals1617from .gazetteer import (18 AMBIGUOUS_TOPONYMS,19 QUEBEC_AREA_CODES,20 QUEBEC_ORGS,21 QUEBEC_TOPONYMS,22 STRONG_DOMAIN_SUFFIXES,23)2425# Codes postaux du Québec : G, H, J en première lettre (format A1A 1A1)26_POSTAL_RE = re.compile(r"\b[GHJ]\d[A-Z]\s?\d[A-Z]\d\b", re.IGNORECASE)27_PHONE_RE = re.compile(r"(?:\+?1[\s.-]?)?\(?(\d{3})\)?[\s.-]?\d{3}[\s.-]?\d{4}\b")28_PROVINCE_RE = re.compile(29 r"\b(?:province\s+(?:de\s+|du\s+)?qu[ée]bec|qu[ée]bec\s*\(qc\)|,\s*(?:qc|qu[ée]bec)\b)",30 re.IGNORECASE,31)32_WORD_BOUNDARY = r"(?<![\w-]){}(?![\w-])"333435def _count_terms(text: str, terms: set[str], cap: int = 10) -> tuple[int, list[str]]:36 found: list[str] = []37 total = 038 for term in terms:39 pattern = re.compile(_WORD_BOUNDARY.format(re.escape(term)), re.IGNORECASE)40 n = len(pattern.findall(text))41 if n:42 found.append(term)43 total += min(n, 4) # une page qui répète 200× « Montréal » n'est pas 200× plus québécoise44 if total >= cap:45 break46 return min(total, cap), found474849def score_page(page: ParsedPage, domain: str) -> QuebecSignals:50 """Score Québec d'une page. Déterministe, sans LLM, économique (§12)."""51 signals = QuebecSignals()52 reasons: list[str] = []53 points = 0.05455 text = " ".join([page.title, page.description, " ".join(page.headings), page.body[:20_000]])56 text_with_hints = text + " " + " ".join(page.structured_hints)57 lower = text_with_hints.lower()5859 # 1. Domaine (signal fort mais pas suffisant seul)60 host = domain.lower()61 if any(host.endswith(suffix) or host == suffix.lstrip(".") for suffix in STRONG_DOMAIN_SUFFIXES):62 points += 4.063 reasons.append("tld_quebec")6465 # 2. Toponymes non ambigus (titre/headings pèsent plus que le corps)66 head_text = " ".join([page.title, page.description, " ".join(page.headings)])67 head_hits, head_names = _count_terms(head_text, QUEBEC_TOPONYMS, cap=6)68 body_hits, body_names = _count_terms(page.body[:20_000], QUEBEC_TOPONYMS, cap=8)69 if head_hits:70 points += 1.2 * head_hits71 reasons.append("toponymes_titre")72 if body_hits:73 points += 0.4 * body_hits74 reasons.append("toponymes_corps")75 signals.locations = sorted({*head_names, *body_names})[:12]7677 # 3. Toponymes ambigus — poids réduit78 amb_hits, amb_names = _count_terms(lower, AMBIGUOUS_TOPONYMS, cap=3)79 if amb_hits:80 points += 0.15 * amb_hits81 reasons.append("toponymes_ambigus")82 signals.locations = sorted({*signals.locations, *amb_names})[:12]8384 # 4. Codes postaux QC (signal fort : preuve d'adresse physique)85 postal_hits = len(set(_POSTAL_RE.findall(text_with_hints)))86 if postal_hits:87 points += min(postal_hits, 3) * 1.588 reasons.append("code_postal_qc")8990 # 5. Mention structurée de la province (adresses, footers)91 if _PROVINCE_RE.search(text_with_hints):92 points += 1.593 reasons.append("province_quebec")9495 # 6. Indicatifs téléphoniques (signal faible)96 area_codes = {m for m in _PHONE_RE.findall(text_with_hints) if m in QUEBEC_AREA_CODES}97 if area_codes:98 points += min(len(area_codes), 2) * 0.699 reasons.append("indicatif_qc")100101 # 7. Organisations québécoises connues102 org_hits, _ = _count_terms(lower, QUEBEC_ORGS, cap=6)103 if org_hits:104 points += 0.8 * org_hits105 reasons.append("organisations_qc")106107 # 8. Langue : le français augmente la probabilité sans la prouver108 if page.language == "fr":109 points += 0.8110 reasons.append("francais")111112 # Saturation douce : 0 pt → 0, ~3 pts → 0.5, ≥9 pts → ~0.95113 signals.score = round(points / (points + 3.0), 4) if points > 0 else 0.0114 signals.reasons = reasons115 return signals116