Python 68.8%
TypeScript 18.6%
CSS 8.7%
JavaScript 3.3%
HTML 0.6%
1# -----------------------------------------------------------------------------2# Rent-Ka — Rental listings aggregator (Canada, outside Québec)3# Author: Simon-Pierre Boucher — contact@spboucher.ai4# quality.py : per-listing completeness score + minimum publication threshold5# A listing under the threshold (no price, no location or no usable6# content) is QUARANTINED (published=0): kept in the DB, re-synced7# normally, but never shown on the 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# residential vocabulary (title/description) — bilingual: legitimate41# listings must never fall into "off-topic" quarantine for lack of words42_RE_RES = re.compile(43 r"chambre|room|studio|appart|apartment|condo|loft|maison|house|"44 r"logement|logis\b|r[ée]sidences?\b|chalet|penthouse|duplex|triplex|"45 r"bachelor|colocation|townhouses?|bungalows?|"46 r"sous-sol|basement|½|1/2|\b\d[.,]5\b|\bdemie?s?\b|\bbed(?:room)?s?\b|"47 r"\bunit[ée]?s?\b|\bpi[eè]ces\b", re.I)4849# non-residential object offered for rent (FR + EN)50_RE_NONRES = re.compile(51 r"stationnements?|parkings?|garages?|entrep[oô]ts?|entreposage|"52 r"espaces? de rangement|places? de (?:stationnement|parking)|"53 r"lockers?|cabanons?|remises?|"54 r"parking (?:spot|space|stall)s?|storage (?:locker|unit|space)s?|"55 r"self.?storage|warehouses?|"56 r"loca(?:l|ux) commercia(?:l|ux)|espaces? commercia(?:l|ux)|\bbureaux?\b|"57 r"commercial (?:space|unit)s?|office space|retail (?:space|unit)s?",58 re.I)5960# description that STARTS with the non-residential object (the ad sells it)61_RE_DESC_LEAD = re.compile(62 r"^\W*(?:grande?s?|petite?s?|beaux?|belles?|beau|magnifiques?|superbes?|"63 r"jolie?s?|large|small|beautiful|great|secure[d]?|heated|indoor|outdoor|"64 r"\d+)?\s*"65 r"(?:stationnements?|parkings?|garages?|entrep[oô]ts?|"66 r"places? de (?:stationnement|parking)|espaces? de (?:rangement|stationnement)|"67 r"bureaux?|loca(?:l|ux) (?:commercia(?:l|ux)|industriels?|professionnels?)|"68 r"parking (?:spot|space|stall)s?|storage (?:locker|unit|space)s?|"69 r"office space|commercial (?:space|unit)s?)\b",70 re.I)7172# commercial / office / industrial space (title or description, FR + EN)73_RE_COMMERCIAL = re.compile(74 r"loca(?:l|ux)\s+(?:commercia(?:l|ux)|industriels?|professionnels?)|"75 r"espaces?\s+(?:de\s+)?bureaux?\b|espaces?\s+commercia(?:l|ux)|"76 r"aménagé\s+en\s+(?:restaurant|commerce|boutique)|"77 r"(?:usage|zonage|bail)\s+commercial|"78 r"commercial\s+(?:space|unit|lease|zoning)s?|office\s+space|"79 r"retail\s+(?:space|unit)s?|industrial\s+(?:space|unit|bay)s?", re.I)8081# « bureau » comme nom de rue (av. Jacques-Bureau…) — pas un local à louer82_RE_STREET_BUREAU = re.compile(83 r"(?:av(?:enue)?|rue|boul(?:evard)?|ch(?:emin)?|pl(?:ace)?)\.?\s+"84 r"(?:\w+[- ])?bureau\b", re.I)8586# nightly / short-term pricing (the "rent" is not a monthly price)87_RE_NIGHTLY = re.compile(88 r"tarif par nuit|prix par nuit|par nuit et non par mois|"89 r"location à la nuit|court terme seulement|"90 r"per night|nightly rate|price per night|short.?term only", re.I)919293def relevance_reason(d: dict) -> str | None:94 """Retourne la raison de quarantaine si l'annonce n'est pas un logement95 à louer au mois (stationnement, garage, entreposage, local commercial,96 objet divers, location à la nuit), sinon None.9798 Les seuils de prix évitent les faux positifs : un vrai logement dont le99 titre mentionne « garage » (ex. « Garage intérieur chauffé » pour un 4½100 à 2 250 $) reste publié, alors qu'un « garage à louer » à 195 $ tombe.101 """102 title = (d.get("title") or "").strip()103 desc = (d.get("description") or "").strip()104 head = desc[:300]105 price = d.get("price")106 priced = isinstance(price, (int, float))107 unit = (d.get("unit_type") or "").strip()108 res_title = bool(_RE_RES.search(title))109 res_text = res_title or bool(_RE_RES.search(head))110111 # 1) le titre vend un stationnement/garage/local, sans vocabulaire logement112 if _RE_NONRES.search(title) and not res_title \113 and not _RE_STREET_BUREAU.search(title) \114 and (not unit or not priced or price < 700):115 return "off-topic: parking/garage/commercial (title)"116 # 2) la description s'ouvre sur l'objet non résidentiel — attrape les117 # titres trompeurs (« Appartement Garage à Louer » à 195 $) et les118 # titres-adresses (« P158 1400 Boul René-Lévesque »)119 if _RE_DESC_LEAD.match(desc) and (120 not priced or price < 450121 or (not res_title and ("à vendre" in head.lower() or "for sale" in head.lower()))):122 return "off-topic: parking/garage/commercial (description)"123 # 3) prix de case de stationnement + aucun vocabulaire logement124 if priced and price < 450 and not res_text and _RE_NONRES.search(head):125 return "off-topic: parking/garage (price + text)"126 # 4) local commercial/bureau/industriel sans aucun vocabulaire logement127 if not res_text and _RE_COMMERCIAL.search(f"{title}\n{head}"):128 return "off-topic: commercial/office space"129 # 5) prix impossible pour un logement + aucun signal logement dans le texte130 # (case de stationnement à titre-adresse, objets divers…)131 if priced and price < 300 and not res_text and unit != "Room":132 return "off-topic: non-residential price, no housing signal"133 # 6) prix à la nuit / courte durée affiché comme loyer mensuel134 if priced and price < 600 and _RE_NIGHTLY.search(desc):135 return "off-topic: nightly/short-term rental"136 return None137138139def _as_list(value) -> list:140 if isinstance(value, list):141 return value142 if isinstance(value, str) and value:143 try:144 out = json.loads(value)145 return out if isinstance(out, list) else []146 except ValueError:147 return []148 return []149150151def _as_dict(value) -> dict:152 if isinstance(value, dict):153 return value154 if isinstance(value, str) and value:155 try:156 out = json.loads(value)157 return out if isinstance(out, dict) else {}158 except ValueError:159 return {}160 return {}161162163def evaluate(d: dict) -> tuple[float, bool, list[str]]:164 """Évalue une annonce (dict aux clés du schéma listings).165166 Retourne (score 0–100, publiable, raisons de quarantaine).167 Accepte indifféremment les champs JSON sérialisés (rangée SQLite) ou168 déjà décodés (Listing.asdict) — images/amenities/details.169 """170 score = 0.0171 reasons: list[str] = []172173 # SCOPE: Rent-Ka covers Canada OUTSIDE Québec — Québec listings (legacy174 # rows or stray connector output) stay in the DB but are never shown.175 if d.get("province") == "QC":176 reasons.append("outside-scope-quebec")177178 # loyer mensuel plausible : hors bornes = donnée corrompue à la source179 # (0,02 $, 1 $, prix « à la nuit »…) — traité comme absence de prix180 price = d.get("price")181 has_price = isinstance(price, (int, float)) and 175 <= price <= 20000182 if has_price:183 score += W_PRICE184185 address = (d.get("address") or "").strip()186 city = (d.get("city") or "").strip()187 sector = (d.get("sector") or "").strip()188 has_coords = d.get("lat") is not None and d.get("lng") is not None189 if address or has_coords:190 score += W_LOCATION191 elif sector:192 score += W_LOCATION * 0.5193 if city:194 score += W_CITY195196 desc = (d.get("description") or "").strip()197 if len(desc) >= 200:198 score += W_DESCRIPTION199 elif len(desc) >= 60:200 score += W_DESCRIPTION * 0.5201202 images = _as_list(d.get("images"))203 n_img = len(images)204 if n_img >= 5:205 score += W_IMAGES206 elif n_img >= 2:207 score += 11208 elif n_img == 1:209 score += 6210211 if (d.get("unit_type") or "").strip() or d.get("bedrooms") is not None:212 score += W_UNIT213 if d.get("availability_date") or (d.get("availability") or "").strip():214 score += W_AVAILABILITY215 if d.get("area_sqft"):216 score += W_AREA217 if d.get("bathrooms") is not None:218 score += W_BATHROOMS219220 amenities = _as_list(d.get("amenities"))221 if len(amenities) >= 3:222 score += W_AMENITIES223 elif len(amenities) >= 1:224 score += W_AMENITIES * 0.5225226 details = _as_dict(d.get("details"))227 if len(details) >= 3:228 score += W_DETAILS229 elif len(details) >= 1:230 score += W_DETAILS * 0.5231232 if (d.get("url") or "").strip():233 score += W_URL234235 score = round(min(score, 100.0), 1)236237 # ---- portes dures de publication ---------------------------------------238 if not has_price:239 reasons.append("no price" if not price else f"implausible price (${price:g})")240 if not (address or has_coords or city):241 reasons.append("no location")242 # contenu exploitable : description OU un minimum de structure243 if len(desc) < 40 and not amenities and not (d.get("unit_type") or "").strip() \244 and d.get("bedrooms") is None:245 reasons.append("no usable content")246 # pertinence : pas un logement à louer au mois (stationnement, garage…)247 rel = relevance_reason(d)248 if rel:249 reasons.append(rel)250 if not reasons and score < PUBLISH_THRESHOLD:251 reasons.append(f"completeness {score:.0f} < threshold {PUBLISH_THRESHOLD}")252253 return score, not reasons, reasons254255256def backfill(limit: int | None = None, verbose: bool = True) -> dict:257 """(Re)calcule completeness/published sur toutes les annonces actives."""258 from . import db259 con = db.connect()260 rows = con.execute(261 "SELECT uid, title, price, address, city, sector, lat, lng, description,"262 " images, unit_type, bedrooms, bathrooms, availability,"263 " availability_date, area_sqft, amenities, details, url, province"264 " FROM listings WHERE active=1"265 + (f" LIMIT {int(limit)}" if limit else "")).fetchall()266 pub = quar = 0267 for r in rows:268 score, ok, reasons = evaluate(dict(r))269 con.execute(270 "UPDATE listings SET completeness=?, published=?, quality_reasons=?"271 " WHERE uid=?",272 (score, int(ok), json.dumps(reasons, ensure_ascii=False) if reasons273 else None, r["uid"]))274 if ok:275 pub += 1276 else:277 quar += 1278 con.commit()279 con.close()280 out = {"evaluated": len(rows), "published": pub, "quarantined": quar}281 if verbose:282 print(f"[rent-ka] quality: {out}")283 return out284