# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # quality.py : score de complétude par annonce + seuil minimum de publication # Une annonce sous le seuil (sans prix, sans localisation ou sans contenu # exploitable) est mise en QUARANTAINE (published=0) : conservée en base, # re-synchronisée normalement, mais jamais affichée sur le site. # ----------------------------------------------------------------------------- from __future__ import annotations import json import os import re # Seuil de publication (score 0–100). Les portes dures (prix, localisation, # contenu) s'appliquent en plus du score — voir evaluate(). PUBLISH_THRESHOLD = 40 # Pondération du score de complétude (total = 100) W_PRICE = 20 # prix mensuel exploitable W_LOCATION = 14 # adresse OU coordonnées GPS (partiel si ville seulement) W_CITY = 4 W_DESCRIPTION = 14 # >= 200 caractères ; partiel si >= 60 W_IMAGES = 16 # 0 -> 0 ; 1 -> 6 ; 2-4 -> 11 ; >= 5 -> 16 W_UNIT = 8 # type d'unité ou nombre de chambres W_AVAILABILITY = 5 W_AREA = 4 W_BATHROOMS = 3 W_AMENITIES = 5 # >= 3 commodités ; partiel si >= 1 W_DETAILS = 4 # >= 3 champs structurés W_URL = 3 # lien vers l'annonce originale (contact / réservation) # ---- pertinence : n'afficher que des LOGEMENTS à louer au mois -------------- # Certaines sources mêlent stationnements, garages, entreposage, locaux # commerciaux, objets divers ou locations à la nuit — souvent à ~200 $/mois. # Ces annonces passent le score de complétude mais ne sont pas des logements : # on les met en quarantaine (published=0) avec une raison « hors sujet ». # vocabulaire logement (titre/description) — inclut les termes anglais du # marché ontarien (expansion 2026-08) : un logement ON légitime ne doit # jamais tomber en quarantaine « hors sujet » faute de vocabulaire _RE_RES = re.compile( r"chambre|room|studio|appart|apartment|condo|loft|maison|house|" r"logement|logis\b|r[ée]sidences?\b|chalet|penthouse|duplex|triplex|" r"bachelor|colocation|townhouses?|bungalows?|" r"sous-sol|basement|½|1/2|\b\d[.,]5\b|\bdemie?s?\b|\bbed(?:room)?s?\b|" r"\bunit[ée]?s?\b|\bpi[eè]ces\b", re.I) # objet non résidentiel proposé à la location _RE_NONRES = re.compile( r"stationnements?|parkings?|garages?|entrep[oô]ts?|entreposage|" r"espaces? de rangement|places? de (?:stationnement|parking)|" r"lockers?|cabanons?|remises?|" r"loca(?:l|ux) commercia(?:l|ux)|espaces? commercia(?:l|ux)|\bbureaux?\b", re.I) # description qui COMMENCE par l'objet non résidentiel (l'annonce vend ça) _RE_DESC_LEAD = re.compile( r"^\W*(?:grande?s?|petite?s?|beaux?|belles?|beau|magnifiques?|superbes?|" r"jolie?s?|\d+)?\s*" r"(?:stationnements?|parkings?|garages?|entrep[oô]ts?|" r"places? de (?:stationnement|parking)|espaces? de (?:rangement|stationnement)|" r"bureaux?|loca(?:l|ux) (?:commercia(?:l|ux)|industriels?|professionnels?))\b", re.I) # local commercial / bureau / industriel (titre ou description) _RE_COMMERCIAL = re.compile( r"loca(?:l|ux)\s+(?:commercia(?:l|ux)|industriels?|professionnels?)|" r"espaces?\s+(?:de\s+)?bureaux?\b|espaces?\s+commercia(?:l|ux)|" r"aménagé\s+en\s+(?:restaurant|commerce|boutique)|" r"(?:usage|zonage|bail)\s+commercial", re.I) # « bureau » comme nom de rue (av. Jacques-Bureau…) — pas un local à louer _RE_STREET_BUREAU = re.compile( r"(?:av(?:enue)?|rue|boul(?:evard)?|ch(?:emin)?|pl(?:ace)?)\.?\s+" r"(?:\w+[- ])?bureau\b", re.I) # location à la nuit / courte durée (le « loyer » n'est pas un prix mensuel) _RE_NIGHTLY = re.compile( r"tarif par nuit|prix par nuit|par nuit et non par mois|" r"location à la nuit|court terme seulement", re.I) def relevance_reason(d: dict) -> str | None: """Retourne la raison de quarantaine si l'annonce n'est pas un logement à louer au mois (stationnement, garage, entreposage, local commercial, objet divers, location à la nuit), sinon None. Les seuils de prix évitent les faux positifs : un vrai logement dont le titre mentionne « garage » (ex. « Garage intérieur chauffé » pour un 4½ à 2 250 $) reste publié, alors qu'un « garage à louer » à 195 $ tombe. """ title = (d.get("title") or "").strip() desc = (d.get("description") or "").strip() head = desc[:300] price = d.get("price") priced = isinstance(price, (int, float)) unit = (d.get("unit_type") or "").strip() res_title = bool(_RE_RES.search(title)) res_text = res_title or bool(_RE_RES.search(head)) # 1) le titre vend un stationnement/garage/local, sans vocabulaire logement if _RE_NONRES.search(title) and not res_title \ and not _RE_STREET_BUREAU.search(title) \ and (not unit or not priced or price < 700): return "hors sujet : stationnement/garage/local (titre)" # 2) la description s'ouvre sur l'objet non résidentiel — attrape les # titres trompeurs (« Appartement Garage à Louer » à 195 $) et les # titres-adresses (« P158 1400 Boul René-Lévesque ») if _RE_DESC_LEAD.match(desc) and ( not priced or price < 450 or (not res_title and "à vendre" in head.lower())): return "hors sujet : stationnement/garage/local (description)" # 3) prix de case de stationnement + aucun vocabulaire logement if priced and price < 450 and not res_text and _RE_NONRES.search(head): return "hors sujet : stationnement/garage (prix + texte)" # 4) local commercial/bureau/industriel sans aucun vocabulaire logement if not res_text and _RE_COMMERCIAL.search(f"{title}\n{head}"): return "hors sujet : local commercial/bureau" # 5) prix impossible pour un logement + aucun signal logement dans le texte # (case de stationnement à titre-adresse, objets divers…) if priced and price < 300 and not res_text and unit != "Chambre": return "hors sujet : prix non résidentiel, aucun signal logement" # 6) prix à la nuit / courte durée affiché comme loyer mensuel if priced and price < 600 and _RE_NIGHTLY.search(desc): return "hors sujet : location à la nuit / court terme" return None def _as_list(value) -> list: if isinstance(value, list): return value if isinstance(value, str) and value: try: out = json.loads(value) return out if isinstance(out, list) else [] except ValueError: return [] return [] def _as_dict(value) -> dict: if isinstance(value, dict): return value if isinstance(value, str) and value: try: out = json.loads(value) return out if isinstance(out, dict) else {} except ValueError: return {} return {} def evaluate(d: dict) -> tuple[float, bool, list[str]]: """Évalue une annonce (dict aux clés du schéma listings). Retourne (score 0–100, publiable, raisons de quarantaine). Accepte indifféremment les champs JSON sérialisés (rangée SQLite) ou déjà décodés (Listing.asdict) — images/amenities/details. """ score = 0.0 reasons: list[str] = [] # PAUSE ONTARIO (2026-08-27) : l'expansion ON est suspendue — les annonces # ON restent en base et re-synchronisables, mais partent en quarantaine # tant que LOUKA_ONTARIO=1 n'est pas reposé dans .env # (voir docs/ONTARIO-PAUSE.md ; réactivation : flag + `run.py quality`). if d.get("province") == "ON" and os.environ.get("LOUKA_ONTARIO") != "1": reasons.append("pause-ontario") # loyer mensuel plausible : hors bornes = donnée corrompue à la source # (0,02 $, 1 $, prix « à la nuit »…) — traité comme absence de prix price = d.get("price") has_price = isinstance(price, (int, float)) and 175 <= price <= 20000 if has_price: score += W_PRICE address = (d.get("address") or "").strip() city = (d.get("city") or "").strip() sector = (d.get("sector") or "").strip() has_coords = d.get("lat") is not None and d.get("lng") is not None if address or has_coords: score += W_LOCATION elif sector: score += W_LOCATION * 0.5 if city: score += W_CITY desc = (d.get("description") or "").strip() if len(desc) >= 200: score += W_DESCRIPTION elif len(desc) >= 60: score += W_DESCRIPTION * 0.5 images = _as_list(d.get("images")) n_img = len(images) if n_img >= 5: score += W_IMAGES elif n_img >= 2: score += 11 elif n_img == 1: score += 6 if (d.get("unit_type") or "").strip() or d.get("bedrooms") is not None: score += W_UNIT if d.get("availability_date") or (d.get("availability") or "").strip(): score += W_AVAILABILITY if d.get("area_sqft"): score += W_AREA if d.get("bathrooms") is not None: score += W_BATHROOMS amenities = _as_list(d.get("amenities")) if len(amenities) >= 3: score += W_AMENITIES elif len(amenities) >= 1: score += W_AMENITIES * 0.5 details = _as_dict(d.get("details")) if len(details) >= 3: score += W_DETAILS elif len(details) >= 1: score += W_DETAILS * 0.5 if (d.get("url") or "").strip(): score += W_URL score = round(min(score, 100.0), 1) # ---- portes dures de publication --------------------------------------- if not has_price: reasons.append("sans prix" if not price else f"prix non plausible ({price:g} $)") if not (address or has_coords or city): reasons.append("sans localisation") # contenu exploitable : description OU un minimum de structure if len(desc) < 40 and not amenities and not (d.get("unit_type") or "").strip() \ and d.get("bedrooms") is None: reasons.append("sans contenu exploitable") # pertinence : pas un logement à louer au mois (stationnement, garage…) rel = relevance_reason(d) if rel: reasons.append(rel) if not reasons and score < PUBLISH_THRESHOLD: reasons.append(f"complétude {score:.0f} < seuil {PUBLISH_THRESHOLD}") return score, not reasons, reasons def backfill(limit: int | None = None, verbose: bool = True) -> dict: """(Re)calcule completeness/published sur toutes les annonces actives.""" from . import db con = db.connect() rows = con.execute( "SELECT uid, title, price, address, city, sector, lat, lng, description," " images, unit_type, bedrooms, bathrooms, availability," " availability_date, area_sqft, amenities, details, url, province" " FROM listings WHERE active=1" + (f" LIMIT {int(limit)}" if limit else "")).fetchall() pub = quar = 0 for r in rows: score, ok, reasons = evaluate(dict(r)) con.execute( "UPDATE listings SET completeness=?, published=?, quality_reasons=?" " WHERE uid=?", (score, int(ok), json.dumps(reasons, ensure_ascii=False) if reasons else None, r["uid"])) if ok: pub += 1 else: quar += 1 con.commit() con.close() out = {"evaluees": len(rows), "publiees": pub, "quarantaine": quar} if verbose: print(f"[lou-ka] quality: {out}") return out