SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%

quality: porte de pertinence — quarantaine des stationnements, garages, locaux commerciaux et locations à la nuit

Certaines sources mêlent aux logements des cases de stationnement (~200 $),
garages/entreposage, locaux commerciaux/bureaux, objets divers et tarifs à la
nuit. relevance_reason() les détecte (titre + description + seuils de prix,
sans faux positif sur les logements « avec stationnement inclus ») et evaluate()
les met en quarantaine (published=0). Backfill appliqué : 78 annonces retirées.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 21, 2026) parent 463c508

3 changed files +204 −1

modified louka/db.py +1 −0
@@ -306,6 +306,7 @@ def sync_source(con: sqlite3.Connection, source: str,
306 306 # score de complétude + seuil de publication (quarantaine sous le seuil)
307 307 from .quality import evaluate
308 308 q_score, q_pub, q_reasons = evaluate({
309 + "title": lst.title,
309 310 "price": lst.price, "address": lst.address, "city": lst.city,
310 311 "sector": lst.sector, "lat": lst.lat, "lng": lst.lng,
311 312 "description": lst.description, "images": lst.images,
modified louka/quality.py +101 −1
@@ -9,6 +9,7 @@
9 9 from __future__ import annotations
10 10
11 11 import json
12 +import re
12 13
13 14 # Seuil de publication (score 0–100). Les portes dures (prix, localisation,
14 15 # contenu) s'appliquent en plus du score — voir evaluate().
@@ -29,6 +30,101 @@ W_DETAILS = 4 # >= 3 champs structurés
29 30 W_URL = 3 # lien vers l'annonce originale (contact / réservation)
30 31
31 32
33 +# ---- pertinence : n'afficher que des LOGEMENTS à louer au mois --------------
34 +# Certaines sources mêlent stationnements, garages, entreposage, locaux
35 +# commerciaux, objets divers ou locations à la nuit — souvent à ~200 $/mois.
36 +# Ces annonces passent le score de complétude mais ne sont pas des logements :
37 +# on les met en quarantaine (published=0) avec une raison « hors sujet ».
38 +
39 +# vocabulaire logement (titre/description)
40 +_RE_RES = re.compile(
41 + r"chambre|room|studio|appart|apartment|condo|loft|maison|house|"
42 + r"logement|logis\b|r[ée]sidences?\b|chalet|penthouse|duplex|triplex|"
43 + r"bachelor|colocation|"
44 + r"sous-sol|basement|½|1/2|\b\d[.,]5\b|\bdemie?s?\b|\bbed(?:room)?s?\b|"
45 + r"\bunit[ée]?s?\b|\bpi[eè]ces\b", re.I)
46 +
47 +# objet non résidentiel proposé à la location
48 +_RE_NONRES = re.compile(
49 + r"stationnements?|parkings?|garages?|entrep[oô]ts?|entreposage|"
50 + r"espaces? de rangement|places? de (?:stationnement|parking)|"
51 + r"lockers?|cabanons?|remises?|"
52 + r"loca(?:l|ux) commercia(?:l|ux)|espaces? commercia(?:l|ux)|\bbureaux?\b",
53 + re.I)
54 +
55 +# description qui COMMENCE par l'objet non résidentiel (l'annonce vend ça)
56 +_RE_DESC_LEAD = re.compile(
57 + r"^\W*(?:grande?s?|petite?s?|beaux?|belles?|beau|magnifiques?|superbes?|"
58 + r"jolie?s?|\d+)?\s*"
59 + r"(?:stationnements?|parkings?|garages?|entrep[oô]ts?|"
60 + r"places? de (?:stationnement|parking)|espaces? de (?:rangement|stationnement)|"
61 + r"bureaux?|loca(?:l|ux) (?:commercia(?:l|ux)|industriels?|professionnels?))\b",
62 + re.I)
63 +
64 +# local commercial / bureau / industriel (titre ou description)
65 +_RE_COMMERCIAL = re.compile(
66 + r"loca(?:l|ux)\s+(?:commercia(?:l|ux)|industriels?|professionnels?)|"
67 + r"espaces?\s+(?:de\s+)?bureaux?\b|espaces?\s+commercia(?:l|ux)|"
68 + r"aménagé\s+en\s+(?:restaurant|commerce|boutique)|"
69 + r"(?:usage|zonage|bail)\s+commercial", re.I)
70 +
71 +# « bureau » comme nom de rue (av. Jacques-Bureau…) — pas un local à louer
72 +_RE_STREET_BUREAU = re.compile(
73 + r"(?:av(?:enue)?|rue|boul(?:evard)?|ch(?:emin)?|pl(?:ace)?)\.?\s+"
74 + r"(?:\w+[- ])?bureau\b", re.I)
75 +
76 +# location à la nuit / courte durée (le « loyer » n'est pas un prix mensuel)
77 +_RE_NIGHTLY = re.compile(
78 + r"tarif par nuit|prix par nuit|par nuit et non par mois|"
79 + r"location à la nuit|court terme seulement", re.I)
80 +
81 +
82 +def relevance_reason(d: dict) -> str | None:
83 + """Retourne la raison de quarantaine si l'annonce n'est pas un logement
84 + à louer au mois (stationnement, garage, entreposage, local commercial,
85 + objet divers, location à la nuit), sinon None.
86 +
87 + Les seuils de prix évitent les faux positifs : un vrai logement dont le
88 + titre mentionne « garage » (ex. « Garage intérieur chauffé » pour un 4½
89 + à 2 250 $) reste publié, alors qu'un « garage à louer » à 195 $ tombe.
90 + """
91 + title = (d.get("title") or "").strip()
92 + desc = (d.get("description") or "").strip()
93 + head = desc[:300]
94 + price = d.get("price")
95 + priced = isinstance(price, (int, float))
96 + unit = (d.get("unit_type") or "").strip()
97 + res_title = bool(_RE_RES.search(title))
98 + res_text = res_title or bool(_RE_RES.search(head))
99 +
100 + # 1) le titre vend un stationnement/garage/local, sans vocabulaire logement
101 + if _RE_NONRES.search(title) and not res_title \
102 + and not _RE_STREET_BUREAU.search(title) \
103 + and (not unit or not priced or price < 700):
104 + return "hors sujet : stationnement/garage/local (titre)"
105 + # 2) la description s'ouvre sur l'objet non résidentiel — attrape les
106 + # titres trompeurs (« Appartement Garage à Louer » à 195 $) et les
107 + # titres-adresses (« P158 1400 Boul René-Lévesque »)
108 + if _RE_DESC_LEAD.match(desc) and (
109 + not priced or price < 450
110 + or (not res_title and "à vendre" in head.lower())):
111 + return "hors sujet : stationnement/garage/local (description)"
112 + # 3) prix de case de stationnement + aucun vocabulaire logement
113 + if priced and price < 450 and not res_text and _RE_NONRES.search(head):
114 + return "hors sujet : stationnement/garage (prix + texte)"
115 + # 4) local commercial/bureau/industriel sans aucun vocabulaire logement
116 + if not res_text and _RE_COMMERCIAL.search(f"{title}\n{head}"):
117 + return "hors sujet : local commercial/bureau"
118 + # 5) prix impossible pour un logement + aucun signal logement dans le texte
119 + # (case de stationnement à titre-adresse, objets divers…)
120 + if priced and price < 300 and not res_text and unit != "Chambre":
121 + return "hors sujet : prix non résidentiel, aucun signal logement"
122 + # 6) prix à la nuit / courte durée affiché comme loyer mensuel
123 + if priced and price < 600 and _RE_NIGHTLY.search(desc):
124 + return "hors sujet : location à la nuit / court terme"
125 + return None
126 +
127 +
32 128 def _as_list(value) -> list:
33 129 if isinstance(value, list):
34 130 return value
@@ -131,6 +227,10 @@ def evaluate(d: dict) -> tuple[float, bool, list[str]]:
131 227 if len(desc) < 40 and not amenities and not (d.get("unit_type") or "").strip() \
132 228 and d.get("bedrooms") is None:
133 229 reasons.append("sans contenu exploitable")
230 + # pertinence : pas un logement à louer au mois (stationnement, garage…)
231 + rel = relevance_reason(d)
232 + if rel:
233 + reasons.append(rel)
134 234 if not reasons and score < PUBLISH_THRESHOLD:
135 235 reasons.append(f"complétude {score:.0f} < seuil {PUBLISH_THRESHOLD}")
136 236
@@ -142,7 +242,7 @@ def backfill(limit: int | None = None, verbose: bool = True) -> dict:
142 242 from . import db
143 243 con = db.connect()
144 244 rows = con.execute(
145 − "SELECT uid, price, address, city, sector, lat, lng, description,"
245 + "SELECT uid, title, price, address, city, sector, lat, lng, description,"
146 246 " images, unit_type, bedrooms, bathrooms, availability,"
147 247 " availability_date, area_sqft, amenities, details, url"
148 248 " FROM listings WHERE active=1"
added tests/test_quality.py +102 −0
@@ -0,0 +1,102 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — tests de la porte de pertinence (quality.relevance_reason)
3 +# Un stationnement/garage/local commercial à louer ne doit jamais être publié ;
4 +# un logement qui INCLUT un stationnement doit rester publié.
5 +# -----------------------------------------------------------------------------
6 +from louka.quality import evaluate, relevance_reason
7 +
8 +
9 +def _reason(**kw):
10 + d = {"title": "", "description": "", "price": None, "unit_type": None,
11 + "bedrooms": None}
12 + d.update(kw)
13 + return relevance_reason(d)
14 +
15 +
16 +# ---- hors sujet : doivent être mis en quarantaine ---------------------------
17 +
18 +def test_parking_titre():
19 + assert _reason(title="Parking à louer", price=200.0)
20 + assert _reason(title="Stationnement extérieur privé à louer – près du métro",
21 + price=100.0)
22 + assert _reason(title="Espace d'entreposage chauffé et éclairé", price=150.0)
23 +
24 +
25 +def test_garage_description_titre_trompeur():
26 + # logisquebec : « Appartement Garage à Louer » à 195 $ = un garage
27 + assert _reason(
28 + title="Costebelle-Banville Appartement Garage à Louer Duberger",
29 + description="Grand garage/entrepôt de 8 pieds par 20 pieds, situé au "
30 + "cœur d'un quartier tranquille.",
31 + price=195.0, unit_type="3½")
32 +
33 +
34 +def test_stationnement_titre_adresse():
35 + # titre-adresse, la description révèle la case de stationnement
36 + assert _reason(title="P158 1400 Boul Rene Levesque O",
37 + description="1 Stationnement intérieur #158 à vendre.",
38 + price=250.0)
39 +
40 +
41 +def test_local_commercial():
42 + assert _reason(title="LOCAL COMMERCIAL À LOUER – CLÉ EN MAIN", price=3650.0)
43 + assert _reason(title="302 600 Rue Sherbrooke E",
44 + description="Espace de bureau lumineux et fonctionnel de "
45 + "290 pi², au centre-ville.", price=800.0)
46 +
47 +
48 +def test_prix_de_case_sans_signal_logement():
49 + assert _reason(title="1288 Rue St-Antoine O. app. P3-151",
50 + description="Un dépot de $100 pour le ouvre garage.",
51 + price=280.0, unit_type="Condo")
52 +
53 +
54 +def test_location_a_la_nuit():
55 + assert _reason(title="44, Perron O, Ste-Anne-Des-Monts à louer",
56 + description="Location court terme seulement. Tarif par nuit "
57 + "et non par mois.", price=210.0,
58 + unit_type="Maison")
59 +
60 +
61 +# ---- logements légitimes : doivent rester publiés ---------------------------
62 +
63 +def test_logement_avec_stationnement_inclus():
64 + assert _reason(title="4½ NDG Stationnement + rangement inclus",
65 + price=1150.0, unit_type="4½") is None
66 + assert _reason(title="Grand 2 1/2 avec électros/internet/stationnement",
67 + price=995.0, unit_type="Studio") is None
68 +
69 +
70 +def test_logement_titre_garage_prix_plausible():
71 + # vrai 4½ dont le titre vante le garage
72 + assert _reason(title="Garage Intérieur Chauffé",
73 + description="Magnifique logement 4 1/2, lumineux, doté d'un "
74 + "balcon, d'un ascenseur.",
75 + price=2250.0, unit_type="4½") is None
76 +
77 +
78 +def test_immeuble_mixte_residentiel():
79 + assert _reason(title="4 Boulevard Cartier O., app. 717",
80 + description="Des résidences exquises et des espaces "
81 + "commerciaux au rez-de-chaussée.",
82 + price=2150.0, unit_type="4½") is None
83 +
84 +
85 +def test_nom_de_rue_bureau():
86 + assert _reason(title="3220 Av Jacques Bureau",
87 + description="Magnifique logement de 2 chambres.",
88 + price=1200.0) is None
89 +
90 +
91 +def test_chambre_pas_chere_reste_publiee():
92 + assert _reason(title="Grande Chambre ($600/mois), tout inclus + parking",
93 + price=600.0, unit_type="Chambre") is None
94 +
95 +
96 +def test_evaluate_integre_la_pertinence():
97 + score, ok, reasons = evaluate({
98 + "title": "Parking à louer", "price": 200.0, "address": "123 rue X",
99 + "city": "Montréal", "description": "Belle place de stationnement " * 10,
100 + "images": ["a.jpg"], "url": "https://x"})
101 + assert not ok
102 + assert any("hors sujet" in r for r in reasons)
103