Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# quality.py : score de complétude par annonce + seuil minimum de publication5# Une annonce sous le seuil (sans prix, sans localisation ou sans contenu6# exploitable) est mise en QUARANTAINE (published=0) : conservée en base,7# re-synchronisée normalement, mais jamais affichée sur le site.8# -----------------------------------------------------------------------------9from __future__ import annotations1011import json12import os13import re1415# Seuil de publication (score 0–100). Les portes dures (prix, localisation,16# contenu) s'appliquent en plus du score — voir evaluate().17PUBLISH_THRESHOLD = 401819# Pondération du score de complétude (total = 100)20W_PRICE = 20 # prix mensuel exploitable21W_LOCATION = 14 # adresse OU coordonnées GPS (partiel si ville seulement)22W_CITY = 423W_DESCRIPTION = 14 # >= 200 caractères ; partiel si >= 6024W_IMAGES = 16 # 0 -> 0 ; 1 -> 6 ; 2-4 -> 11 ; >= 5 -> 1625W_UNIT = 8 # type d'unité ou nombre de chambres26W_AVAILABILITY = 527W_AREA = 428W_BATHROOMS = 329W_AMENITIES = 5 # >= 3 commodités ; partiel si >= 130W_DETAILS = 4 # >= 3 champs structurés31W_URL = 3 # lien vers l'annonce originale (contact / réservation)323334# ---- pertinence : n'afficher que des LOGEMENTS à louer au mois --------------35# Certaines sources mêlent stationnements, garages, entreposage, locaux36# commerciaux, objets divers ou locations à la nuit — souvent à ~200 $/mois.37# Ces annonces passent le score de complétude mais ne sont pas des logements :38# on les met en quarantaine (published=0) avec une raison « hors sujet ».3940# vocabulaire logement (titre/description) — inclut les termes anglais du41# marché ontarien (expansion 2026-08) : un logement ON légitime ne doit42# jamais tomber en quarantaine « hors sujet » faute de vocabulaire43_RE_RES = re.compile(44 r"chambre|room|studio|appart|apartment|condo|loft|maison|house|"45 r"logement|logis\b|r[ée]sidences?\b|chalet|penthouse|duplex|triplex|"46 r"bachelor|colocation|townhouses?|bungalows?|"47 r"sous-sol|basement|½|1/2|\b\d[.,]5\b|\bdemie?s?\b|\bbed(?:room)?s?\b|"48 r"\bunit[ée]?s?\b|\bpi[eè]ces\b", re.I)4950# objet non résidentiel proposé à la location51_RE_NONRES = re.compile(52 r"stationnements?|parkings?|garages?|entrep[oô]ts?|entreposage|"53 r"espaces? de rangement|places? de (?:stationnement|parking)|"54 r"lockers?|cabanons?|remises?|"55 r"loca(?:l|ux) commercia(?:l|ux)|espaces? commercia(?:l|ux)|\bbureaux?\b",56 re.I)5758# description qui COMMENCE par l'objet non résidentiel (l'annonce vend ça)59_RE_DESC_LEAD = re.compile(60 r"^\W*(?:grande?s?|petite?s?|beaux?|belles?|beau|magnifiques?|superbes?|"61 r"jolie?s?|\d+)?\s*"62 r"(?:stationnements?|parkings?|garages?|entrep[oô]ts?|"63 r"places? de (?:stationnement|parking)|espaces? de (?:rangement|stationnement)|"64 r"bureaux?|loca(?:l|ux) (?:commercia(?:l|ux)|industriels?|professionnels?))\b",65 re.I)6667# local commercial / bureau / industriel (titre ou description)68_RE_COMMERCIAL = re.compile(69 r"loca(?:l|ux)\s+(?:commercia(?:l|ux)|industriels?|professionnels?)|"70 r"espaces?\s+(?:de\s+)?bureaux?\b|espaces?\s+commercia(?:l|ux)|"71 r"aménagé\s+en\s+(?:restaurant|commerce|boutique)|"72 r"(?:usage|zonage|bail)\s+commercial", re.I)7374# « bureau » comme nom de rue (av. Jacques-Bureau…) — pas un local à louer75_RE_STREET_BUREAU = re.compile(76 r"(?:av(?:enue)?|rue|boul(?:evard)?|ch(?:emin)?|pl(?:ace)?)\.?\s+"77 r"(?:\w+[- ])?bureau\b", re.I)7879# location à la nuit / courte durée (le « loyer » n'est pas un prix mensuel)80_RE_NIGHTLY = re.compile(81 r"tarif par nuit|prix par nuit|par nuit et non par mois|"82 r"location à la nuit|court terme seulement", re.I)838485def relevance_reason(d: dict) -> str | None:86 """Retourne la raison de quarantaine si l'annonce n'est pas un logement87 à louer au mois (stationnement, garage, entreposage, local commercial,88 objet divers, location à la nuit), sinon None.8990 Les seuils de prix évitent les faux positifs : un vrai logement dont le91 titre mentionne « garage » (ex. « Garage intérieur chauffé » pour un 4½92 à 2 250 $) reste publié, alors qu'un « garage à louer » à 195 $ tombe.93 """94 title = (d.get("title") or "").strip()95 desc = (d.get("description") or "").strip()96 head = desc[:300]97 price = d.get("price")98 priced = isinstance(price, (int, float))99 unit = (d.get("unit_type") or "").strip()100 res_title = bool(_RE_RES.search(title))101 res_text = res_title or bool(_RE_RES.search(head))102103 # 1) le titre vend un stationnement/garage/local, sans vocabulaire logement104 if _RE_NONRES.search(title) and not res_title \105 and not _RE_STREET_BUREAU.search(title) \106 and (not unit or not priced or price < 700):107 return "hors sujet : stationnement/garage/local (titre)"108 # 2) la description s'ouvre sur l'objet non résidentiel — attrape les109 # titres trompeurs (« Appartement Garage à Louer » à 195 $) et les110 # titres-adresses (« P158 1400 Boul René-Lévesque »)111 if _RE_DESC_LEAD.match(desc) and (112 not priced or price < 450113 or (not res_title and "à vendre" in head.lower())):114 return "hors sujet : stationnement/garage/local (description)"115 # 3) prix de case de stationnement + aucun vocabulaire logement116 if priced and price < 450 and not res_text and _RE_NONRES.search(head):117 return "hors sujet : stationnement/garage (prix + texte)"118 # 4) local commercial/bureau/industriel sans aucun vocabulaire logement119 if not res_text and _RE_COMMERCIAL.search(f"{title}\n{head}"):120 return "hors sujet : local commercial/bureau"121 # 5) prix impossible pour un logement + aucun signal logement dans le texte122 # (case de stationnement à titre-adresse, objets divers…)123 if priced and price < 300 and not res_text and unit != "Chambre":124 return "hors sujet : prix non résidentiel, aucun signal logement"125 # 6) prix à la nuit / courte durée affiché comme loyer mensuel126 if priced and price < 600 and _RE_NIGHTLY.search(desc):127 return "hors sujet : location à la nuit / court terme"128 return None129130131def _as_list(value) -> list:132 if isinstance(value, list):133 return value134 if isinstance(value, str) and value:135 try:136 out = json.loads(value)137 return out if isinstance(out, list) else []138 except ValueError:139 return []140 return []141142143def _as_dict(value) -> dict:144 if isinstance(value, dict):145 return value146 if isinstance(value, str) and value:147 try:148 out = json.loads(value)149 return out if isinstance(out, dict) else {}150 except ValueError:151 return {}152 return {}153154155def evaluate(d: dict) -> tuple[float, bool, list[str]]:156 """Évalue une annonce (dict aux clés du schéma listings).157158 Retourne (score 0–100, publiable, raisons de quarantaine).159 Accepte indifféremment les champs JSON sérialisés (rangée SQLite) ou160 déjà décodés (Listing.asdict) — images/amenities/details.161 """162 score = 0.0163 reasons: list[str] = []164165 # PAUSE ONTARIO (2026-08-27) : l'expansion ON est suspendue — les annonces166 # ON restent en base et re-synchronisables, mais partent en quarantaine167 # tant que LOUKA_ONTARIO=1 n'est pas reposé dans .env168 # (voir docs/ONTARIO-PAUSE.md ; réactivation : flag + `run.py quality`).169 if d.get("province") == "ON" and os.environ.get("LOUKA_ONTARIO") != "1":170 reasons.append("pause-ontario")171172 # loyer mensuel plausible : hors bornes = donnée corrompue à la source173 # (0,02 $, 1 $, prix « à la nuit »…) — traité comme absence de prix174 price = d.get("price")175 has_price = isinstance(price, (int, float)) and 175 <= price <= 20000176 if has_price:177 score += W_PRICE178179 address = (d.get("address") or "").strip()180 city = (d.get("city") or "").strip()181 sector = (d.get("sector") or "").strip()182 has_coords = d.get("lat") is not None and d.get("lng") is not None183 if address or has_coords:184 score += W_LOCATION185 elif sector:186 score += W_LOCATION * 0.5187 if city:188 score += W_CITY189190 desc = (d.get("description") or "").strip()191 if len(desc) >= 200:192 score += W_DESCRIPTION193 elif len(desc) >= 60:194 score += W_DESCRIPTION * 0.5195196 images = _as_list(d.get("images"))197 n_img = len(images)198 if n_img >= 5:199 score += W_IMAGES200 elif n_img >= 2:201 score += 11202 elif n_img == 1:203 score += 6204205 if (d.get("unit_type") or "").strip() or d.get("bedrooms") is not None:206 score += W_UNIT207 if d.get("availability_date") or (d.get("availability") or "").strip():208 score += W_AVAILABILITY209 if d.get("area_sqft"):210 score += W_AREA211 if d.get("bathrooms") is not None:212 score += W_BATHROOMS213214 amenities = _as_list(d.get("amenities"))215 if len(amenities) >= 3:216 score += W_AMENITIES217 elif len(amenities) >= 1:218 score += W_AMENITIES * 0.5219220 details = _as_dict(d.get("details"))221 if len(details) >= 3:222 score += W_DETAILS223 elif len(details) >= 1:224 score += W_DETAILS * 0.5225226 if (d.get("url") or "").strip():227 score += W_URL228229 score = round(min(score, 100.0), 1)230231 # ---- portes dures de publication ---------------------------------------232 if not has_price:233 reasons.append("sans prix" if not price else f"prix non plausible ({price:g} $)")234 if not (address or has_coords or city):235 reasons.append("sans localisation")236 # contenu exploitable : description OU un minimum de structure237 if len(desc) < 40 and not amenities and not (d.get("unit_type") or "").strip() \238 and d.get("bedrooms") is None:239 reasons.append("sans contenu exploitable")240 # pertinence : pas un logement à louer au mois (stationnement, garage…)241 rel = relevance_reason(d)242 if rel:243 reasons.append(rel)244 if not reasons and score < PUBLISH_THRESHOLD:245 reasons.append(f"complétude {score:.0f} < seuil {PUBLISH_THRESHOLD}")246247 return score, not reasons, reasons248249250def backfill(limit: int | None = None, verbose: bool = True) -> dict:251 """(Re)calcule completeness/published sur toutes les annonces actives."""252 from . import db253 con = db.connect()254 rows = con.execute(255 "SELECT uid, title, price, address, city, sector, lat, lng, description,"256 " images, unit_type, bedrooms, bathrooms, availability,"257 " availability_date, area_sqft, amenities, details, url, province"258 " FROM listings WHERE active=1"259 + (f" LIMIT {int(limit)}" if limit else "")).fetchall()260 pub = quar = 0261 for r in rows:262 score, ok, reasons = evaluate(dict(r))263 con.execute(264 "UPDATE listings SET completeness=?, published=?, quality_reasons=?"265 " WHERE uid=?",266 (score, int(ok), json.dumps(reasons, ensure_ascii=False) if reasons267 else None, r["uid"]))268 if ok:269 pub += 1270 else:271 quar += 1272 con.commit()273 con.close()274 out = {"evaluees": len(rows), "publiees": pub, "quarantaine": quar}275 if verbose:276 print(f"[lou-ka] quality: {out}")277 return out278