| 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 |
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 |
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 |
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" |