| 2 |
2 |
# Lou-Ka — Agrégateur de logements à louer (province de Québec) |
| 3 |
3 |
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 4 |
4 |
# connectors/tri_logis.py : connecteur Société immobilière Tri-Logis inc. |
| 5 |
|
−# (tri-logis.ca — Rouyn-Noranda, 600+ logements, référence n°1 en |
| 6 |
|
−# Abitibi-Témiscamingue). Site custom statique, tout rendu serveur. |
| 7 |
|
−# Liste /espaces-a-louer/logements : un bloc par immeuble (adresse, secteur, |
| 8 |
|
−# proximité) ; seules les unités disponibles y ont une rangée (type, texte de |
| 9 |
|
−# disponibilité, prix). Fiche unité /espaces-a-louer/immeuble/<imm>/<unité> |
| 10 |
|
−# (via cache BD) : adresse complète avec code postal, description (bloc |
| 11 |
|
−# « Apartment Features » : étage, inclusions, animaux…), galerie pleine |
| 12 |
|
−# taille, inclusions. Périmètre : logements résidentiels seulement — les |
| 13 |
|
−# pages /studios (meublés loués à la nuitée : « les prix des locations de |
| 14 |
|
−# moins de 31 nuitées… ») et /chalets (court terme) sont exclues. |
| 15 |
|
−# Pas de robots.txt (= tout permis). |
|
5 |
+# (trilogis.ca — Rouyn-Noranda, 600+ logements, référence n°1 en |
|
6 |
+# Abitibi-Témiscamingue). Réécrit 2026-08-23 : l'ancien site statique |
|
7 |
+# tri-logis.ca (/espaces-a-louer/logements) a migré vers trilogis.ca |
|
8 |
+# (301) — Next.js App Router (Vercel) + Supabase (projet |
|
9 |
+# tcrymlwdnwmfmpnkrfeu.supabase.co, bucket privé property-media). |
|
10 |
+# Méthode : la liste /a-louer est rendue serveur ; le payload RSC embarqué |
|
11 |
+# dans le HTML (segments self.__next_f.push) contient le tableau JSON |
|
12 |
+# `"units":[…]` complet des unités actuellement à louer — unit_id (UUID), |
|
13 |
+# unit_name (slug de la fiche = minuscules), unit_type (residential / |
|
14 |
+# commercial), building_address, status (vacant / departure_planned), |
|
15 |
+# available_date, monthly_rent, rooms_notation (« 4 ½ »), bedrooms |
|
16 |
+# (cohérent : n½ -> n-2), square_feet, sector, latitude/longitude, |
|
17 |
+# cover_photo_url et le dict inclusions (heating/electricity/… = |
|
18 |
+# landlord|tenant|na ; parking/laundry/furnished/balcony/pets = bool). |
|
19 |
+# Fiche /a-louer/<slug> (RSC aussi) : galerie complète `"photos":[…]`. |
|
20 |
+# ⚠ Les URLs d'images sont SIGNÉES (bucket privé, token ~7 jours, l'accès |
|
21 |
+# public renvoie 400) : elles sont re-signées à chaque rendu, donc on |
|
22 |
+# rafraîchit la galerie à chaque sync (clé de cache = iat du token de la |
|
23 |
+# couverture) plutôt que de servir des liens expirés. La clé anon Supabase |
|
24 |
+# n'est PAS exposée dans les chunks JS (tout est rendu serveur) : l'API |
|
25 |
+# REST /rest/v1 n'est donc pas utilisable — le RSC est la voie robuste. |
|
26 |
+# Périmètre : logements résidentiels longue durée seulement (unit_type |
|
27 |
+# == "residential") ; les locaux commerciaux du même payload sont exclus, |
|
28 |
+# comme l'étaient /studios et /chalets sur l'ancien site. |
| 16 |
29 |
# ----------------------------------------------------------------------------- |
| 17 |
30 |
from __future__ import annotations |
| 18 |
31 |
|
| 19 |
32 |
import hashlib |
|
33 |
+import json |
| 20 |
34 |
import re |
| 21 |
35 |
|
| 22 |
|
−from bs4 import BeautifulSoup |
| 23 |
|
− |
| 24 |
|
−from ..schema import Listing, normalize_unit_type, parse_price, strip_accents |
|
36 |
+from ..schema import Listing, normalize_unit_type |
| 25 |
37 |
from .base import BaseConnector |
| 26 |
38 |
|
| 27 |
|
−BASE = "https://tri-logis.ca" |
| 28 |
|
−LIST_URL = f"{BASE}/espaces-a-louer/logements" |
| 29 |
|
− |
| 30 |
|
−# vignette redimensionnée « /_t282x186/ » ou « /_t600x407/ » -> pleine taille |
| 31 |
|
−_THUMB_RE = re.compile(r"/_t\d+x\d+/") |
| 32 |
|
− |
| 33 |
|
− |
| 34 |
|
−def _pets_value(raw: str) -> str | None: |
| 35 |
|
− """Ligne « Pets allowed: … » de la fiche -> oui/non/conditions.""" |
| 36 |
|
− k = strip_accents((raw or "").strip().lower()) |
| 37 |
|
− if not k: |
|
39 |
+BASE = "https://trilogis.ca" |
|
40 |
+LIST_URL = f"{BASE}/a-louer" |
|
41 |
+ |
|
42 |
+# segments RSC injectés par Next.js dans le HTML rendu serveur |
|
43 |
+_PUSH_RE = re.compile(r'self\.__next_f\.push\(\[1,\s*"(.*?)"\]\)', re.S) |
|
44 |
+# iat du token de signature Supabase (change à chaque rendu de la page) |
|
45 |
+_IAT_RE = re.compile(r'"iat":(\d+)') |
|
46 |
+ |
|
47 |
+# inclusions « landlord » (payées par le proprio) -> libellé affichable |
|
48 |
+_INCL_LANDLORD = { |
|
49 |
+ "heating": "Chauffage inclus", |
|
50 |
+ "electricity": "Électricité incluse", |
|
51 |
+ "water": "Eau incluse", |
|
52 |
+ "hot_water": "Eau chaude incluse", |
|
53 |
+ "internet": "Internet inclus", |
|
54 |
+ "snow_entrance": "Déneigement de l'entrée inclus", |
|
55 |
+ "snow_parking": "Déneigement du stationnement inclus", |
|
56 |
+} |
|
57 |
+# inclusions booléennes -> commodité |
|
58 |
+_INCL_BOOL = { |
|
59 |
+ "parking": "Stationnement", |
|
60 |
+ "laundry": "Buanderie", |
|
61 |
+ "balcony": "Balcon", |
|
62 |
+} |
|
63 |
+# statuts observés -> texte de disponibilité |
|
64 |
+_STATUS_FR = { |
|
65 |
+ "vacant": "Disponible dès maintenant", |
|
66 |
+ "departure_planned": "Bientôt disponible", |
|
67 |
+} |
|
68 |
+ |
|
69 |
+ |
|
70 |
+def _rsc_blob(html: str) -> str: |
|
71 |
+ """Reconstitue le flux RSC (React Server Components) embarqué dans le HTML. |
|
72 |
+ |
|
73 |
+ Les segments sont des chaînes JS échappées façon JSON ; on les décode via |
|
74 |
+ json.loads pour restituer les \\uXXXX et \\" avant de les concaténer. |
|
75 |
+ """ |
|
76 |
+ parts: list[str] = [] |
|
77 |
+ for raw in _PUSH_RE.findall(html): |
|
78 |
+ try: |
|
79 |
+ parts.append(json.loads(f'"{raw}"')) |
|
80 |
+ except ValueError: |
|
81 |
+ parts.append(raw) # segment atypique : garder brut |
|
82 |
+ return "".join(parts) |
|
83 |
+ |
|
84 |
+ |
|
85 |
+def _extract_array(blob: str, key: str) -> list | None: |
|
86 |
+ """Extrait le tableau JSON qui suit `"<key>":` dans le flux RSC.""" |
|
87 |
+ i = blob.find(f'"{key}":') |
|
88 |
+ if i < 0: |
|
89 |
+ return None |
|
90 |
+ try: |
|
91 |
+ value, _ = json.JSONDecoder().raw_decode(blob, i + len(key) + 3) |
|
92 |
+ except ValueError: |
| 38 |
93 |
return None |
| 39 |
|
− if re.search(r"\bno\b|not allowed|aucun|non\b", k): |
| 40 |
|
− return "non" |
| 41 |
|
− if re.search(r"cats and dogs|chats et chiens|\byes\b|allowed", k): |
| 42 |
|
− return "oui" |
| 43 |
|
− if re.search(r"cat|chat|dog|chien|small|petit", k): |
| 44 |
|
− return "conditions" |
| 45 |
|
− return None |
|
94 |
+ return value if isinstance(value, list) else None |
| 46 |
95 |
|
| 47 |
96 |
|
| 48 |
97 |
class TriLogisConnector(BaseConnector): |
| 49 |
98 |
source_id = "tri_logis" |
| 50 |
99 |
request_delay = 0.6 |
| 51 |
|
− max_details = 40 # garde-fou fiches unité (vraies requêtes par sync) |
|
100 |
+ max_details = 60 # garde-fou fiches unité (vraies requêtes par sync) |
| 52 |
101 |
|
| 53 |
102 |
def fetch(self) -> list[Listing]: |
| 54 |
103 |
html = self.get(LIST_URL).text |
| 55 |
|
− soup = BeautifulSoup(html, "html.parser") |
|
104 |
+ blob = _rsc_blob(html) |
|
105 |
+ units = _extract_array(blob, "units") |
|
106 |
+ if units is None: |
|
107 |
+ # marqueur absent = structure du site changée (≠ 0 disponibilité, |
|
108 |
+ # où "units":[] serait présent) -> échec franc plutôt que found=0 |
|
109 |
+ raise RuntimeError("payload RSC sans tableau \"units\" — " |
|
110 |
+ "structure trilogis.ca/a-louer changée ?") |
|
111 |
+ |
|
112 |
+ # les tokens de signature sont régénérés à chaque rendu : leur iat |
|
113 |
+ # sert de sel de cache pour re-signer la galerie à chaque sync |
|
114 |
+ m = _IAT_RE.search(blob) |
|
115 |
+ render_iat = m.group(1) if m else "" |
| 56 |
116 |
|
| 57 |
117 |
self._fetched = 0 |
| 58 |
118 |
listings: dict[str, Listing] = {} |
| 59 |
|
− for bloc in soup.select(".search-results .result.immeuble"): |
|
119 |
+ for u in units: |
| 60 |
120 |
try: |
| 61 |
|
− self._parse_building(bloc, listings) |
|
121 |
+ lst = self._parse_unit(u, render_iat) |
| 62 |
122 |
except Exception: |
| 63 |
123 |
continue |
|
124 |
+ if lst is not None and lst.external_id not in listings: |
|
125 |
+ listings[lst.external_id] = lst |
| 64 |
126 |
return list(listings.values()) |
| 65 |
127 |
|
| 66 |
|
− # -- bloc immeuble (liste) ------------------------------------------------------ |
| 67 |
|
− def _parse_building(self, bloc, listings: dict[str, Listing]) -> None: |
| 68 |
|
− h3 = bloc.select_one(".result-details h3") |
| 69 |
|
− building = h3.get_text(strip=True) if h3 else "" |
| 70 |
|
− |
| 71 |
|
− sector_el = bloc.select_one(".result-details p strong") |
| 72 |
|
− sector = sector_el.get_text(strip=True) if sector_el else "" |
| 73 |
|
− |
| 74 |
|
− # « Proximité : IGA Roy, École…, parc » -> commodités de l'immeuble |
| 75 |
|
− proximity = "" |
| 76 |
|
− for h5 in bloc.select(".result-details h5"): |
| 77 |
|
− if "proximit" in strip_accents(h5.get_text(strip=True).lower()): |
| 78 |
|
− p = h5.find_next_sibling("p") |
| 79 |
|
− if p: |
| 80 |
|
− proximity = re.sub(r"\s+", " ", p.get_text(" ", strip=True)) |
| 81 |
|
− break |
| 82 |
|
− |
| 83 |
|
− # rangées d'unités disponibles (absentes quand 0 espace à louer) |
| 84 |
|
− for a in bloc.select("a.apartment-details[href]"): |
| 85 |
|
− url = a["href"] |
| 86 |
|
− m = re.search(r"/espaces-a-louer/immeuble/([^/]+)/([^/?#]+)", url) |
| 87 |
|
− if not m: |
| 88 |
|
− continue |
| 89 |
|
− ext_id = f"{m.group(1)}--{m.group(2)}" |
| 90 |
|
− if ext_id in listings: |
| 91 |
|
− continue |
| 92 |
|
− |
| 93 |
|
− type_el = a.find("h4") |
| 94 |
|
− unit_label = type_el.get_text(strip=True) if type_el else "" |
| 95 |
|
− |
| 96 |
|
− # texte de disponibilité (« Disponible dès maintenant », « Disponible |
| 97 |
|
− # le 24 juil. 2024 »…) — sans le bouton « Planifier une visite » |
| 98 |
|
− availability = "" |
| 99 |
|
− p = a.find("p") |
| 100 |
|
− if p: |
| 101 |
|
− txt = re.sub(r"\s+", " ", p.get_text(" ", strip=True)) |
| 102 |
|
− m_av = re.search(r"(Disponible[^|]*?)(?:Planifier|$)", txt, re.I) |
| 103 |
|
− if m_av: |
| 104 |
|
− availability = m_av.group(1).strip() |
| 105 |
|
− |
| 106 |
|
− # prix : <div class="price"><div>1,160 $</div><div>mois</div></div> |
| 107 |
|
− price_label = "" |
| 108 |
|
− price_el = a.select_one(".price") |
| 109 |
|
− if price_el: |
| 110 |
|
− price_label = re.sub(r"\s+", " ", price_el.get_text(" ", strip=True)) |
| 111 |
|
− price = parse_price(re.sub(r"(\d),(\d{3})", r"\1\2", price_label)) |
| 112 |
|
− |
| 113 |
|
− # inclusions annoncées sur la carte (« Inclus dans le prix » + liste) |
| 114 |
|
− amenities: list[str] = [] |
| 115 |
|
− for h5 in a.find_all("h5"): |
| 116 |
|
− if "inclus" in strip_accents(h5.get_text(strip=True).lower()): |
| 117 |
|
− sib = h5.find_next_sibling("p") |
| 118 |
|
− if sib: |
| 119 |
|
− t = re.sub(r"\s+", " ", sib.get_text(" ", strip=True)) |
| 120 |
|
− if t: |
| 121 |
|
− amenities.append(t) |
| 122 |
|
− if proximity: |
| 123 |
|
− amenities.append(f"À proximité : {proximity}") |
| 124 |
|
− |
| 125 |
|
− images = [img["src"] for img in a.select("img.img-responsive[src]") |
| 126 |
|
− if img["src"].startswith("http")] |
| 127 |
|
− |
| 128 |
|
− lst = Listing( |
| 129 |
|
− source=self.source_id, |
| 130 |
|
− external_id=ext_id, |
| 131 |
|
− url=url, |
| 132 |
|
− title=f"{building} — {unit_label}".strip(" —"), |
| 133 |
|
− address=building, # affinée par la fiche unité |
| 134 |
|
− sector=sector, |
| 135 |
|
− city="Rouyn-Noranda", |
| 136 |
|
− unit_type=normalize_unit_type(unit_label), |
| 137 |
|
− price=price, |
| 138 |
|
− price_label=price_label, |
| 139 |
|
− availability=availability, |
| 140 |
|
− amenities=amenities, |
| 141 |
|
− details={"building": building}, |
| 142 |
|
− images=[_THUMB_RE.sub("/", u) for u in images], |
| 143 |
|
− ) |
| 144 |
|
− |
|
128 |
+ # -- unité (objet JSON du payload liste) ------------------------------------- |
|
129 |
+ def _parse_unit(self, u: dict, render_iat: str) -> Listing | None: |
|
130 |
+ if (u.get("unit_type") or "") != "residential": |
|
131 |
+ return None # locaux commerciaux exclus |
|
132 |
+ ext_id = str(u.get("unit_id") or "").strip() |
|
133 |
+ unit_name = str(u.get("unit_name") or "").strip() |
|
134 |
+ if not ext_id or not unit_name: |
|
135 |
+ return None |
|
136 |
+ |
|
137 |
+ slug = unit_name.lower() # ex. 60-12_Perreault-E -> fiche |
|
138 |
+ url = f"{BASE}/a-louer/{slug}" |
|
139 |
+ |
|
140 |
+ address = str(u.get("building_address") or "").strip() |
|
141 |
+ # « 58-60 Perreault-E, Rouyn-Noranda » -> ville après la virgule |
|
142 |
+ city = "Rouyn-Noranda" |
|
143 |
+ if "," in address: |
|
144 |
+ tail = address.rsplit(",", 1)[1].strip() |
|
145 |
+ if tail: |
|
146 |
+ city = tail |
|
147 |
+ |
|
148 |
+ rooms = str(u.get("rooms_notation") or "").strip() |
|
149 |
+ status = str(u.get("status") or "").strip() |
|
150 |
+ avail_date = str(u.get("available_date") or "").strip() |
|
151 |
+ if status == "vacant": |
|
152 |
+ availability = _STATUS_FR["vacant"] |
|
153 |
+ availability_date = "now" |
|
154 |
+ elif avail_date: |
|
155 |
+ availability = f"Disponible le {avail_date}" |
|
156 |
+ availability_date = avail_date |
|
157 |
+ else: |
|
158 |
+ availability = _STATUS_FR.get(status, "") |
|
159 |
+ availability_date = None |
|
160 |
+ |
|
161 |
+ price = u.get("monthly_rent") |
|
162 |
+ price = float(price) if isinstance(price, (int, float)) else None |
|
163 |
+ price_label = f"{price:.0f} $ / mois" if price else "" |
|
164 |
+ |
|
165 |
+ # inclusions -> commodités affichables + animaux/meublé |
|
166 |
+ incl = u.get("inclusions") or {} |
|
167 |
+ amenities: list[str] = [] |
|
168 |
+ for k, label in _INCL_LANDLORD.items(): |
|
169 |
+ if incl.get(k) == "landlord": |
|
170 |
+ amenities.append(label) |
|
171 |
+ for k, label in _INCL_BOOL.items(): |
|
172 |
+ if incl.get(k) is True: |
|
173 |
+ amenities.append(label) |
|
174 |
+ pets = None |
|
175 |
+ if isinstance(incl.get("pets"), bool): |
|
176 |
+ pets = "oui" if incl["pets"] else "non" |
|
177 |
+ furnished = incl.get("furnished") if isinstance( |
|
178 |
+ incl.get("furnished"), bool) else None |
|
179 |
+ |
|
180 |
+ bedrooms = u.get("bedrooms") |
|
181 |
+ bedrooms = float(bedrooms) if isinstance(bedrooms, (int, float)) else None |
|
182 |
+ |
|
183 |
+ sqft = u.get("square_feet") |
|
184 |
+ sqft = float(sqft) if isinstance(sqft, (int, float)) and sqft > 0 else None |
|
185 |
+ |
|
186 |
+ images = [u["cover_photo_url"]] if str( |
|
187 |
+ u.get("cover_photo_url") or "").startswith("http") else [] |
|
188 |
+ |
|
189 |
+ lst = Listing( |
|
190 |
+ source=self.source_id, |
|
191 |
+ external_id=ext_id, |
|
192 |
+ url=url, |
|
193 |
+ title=f"{rooms or 'Logement'} — {address.split(',')[0].strip()}", |
|
194 |
+ address=address, |
|
195 |
+ sector=str(u.get("sector") or "").strip(), |
|
196 |
+ city=city, |
|
197 |
+ unit_type=normalize_unit_type(rooms), |
|
198 |
+ bedrooms=bedrooms, |
|
199 |
+ price=price, |
|
200 |
+ price_label=price_label, |
|
201 |
+ availability=availability, |
|
202 |
+ availability_date=availability_date, |
|
203 |
+ area_sqft=sqft, |
|
204 |
+ pets=pets, |
|
205 |
+ furnished=furnished, |
|
206 |
+ description=str(u.get("description_publique") or "").strip()[:1500], |
|
207 |
+ amenities=amenities, |
|
208 |
+ details={"building": str(u.get("building_name") or ""), |
|
209 |
+ "status": status}, |
|
210 |
+ images=images, |
|
211 |
+ lat=u.get("latitude") if isinstance(u.get("latitude"), float) else None, |
|
212 |
+ lng=u.get("longitude") if isinstance(u.get("longitude"), float) else None, |
|
213 |
+ ) |
|
214 |
+ |
|
215 |
+ # galerie complète via la fiche — la clé inclut l'iat du rendu : les |
|
216 |
+ # URLs signées expirent (~7 j), on les re-signe donc à chaque sync |
|
217 |
+ if (u.get("photo_count") or 0) > 1: |
| 145 |
218 |
key = hashlib.sha1( |
| 146 |
|
− f"{unit_label}|{price_label}|{availability}".encode("utf-8") |
| 147 |
|
− ).hexdigest() |
|
219 |
+ f"{price_label}|{availability}|{u.get('photo_count')}|{render_iat}" |
|
220 |
+ .encode("utf-8")).hexdigest() |
| 148 |
221 |
try: |
| 149 |
222 |
payload = self.detail(ext_id, key, |
| 150 |
|
− lambda u=url: self._fetch_detail(u)) |
| 151 |
|
− self._apply_detail(lst, payload) |
|
223 |
+ lambda s=slug: self._fetch_detail(s)) |
|
224 |
+ if payload.get("images"): |
|
225 |
+ lst.images = payload["images"] |
| 152 |
226 |
except Exception: |
| 153 |
227 |
pass |
| 154 |
|
− listings[ext_id] = lst |
|
228 |
+ return lst |
| 155 |
229 |
|
| 156 |
|
− # -- fiche unité ------------------------------------------------------------------ |
| 157 |
|
− def _fetch_detail(self, url: str) -> dict: |
| 158 |
|
− """Adresse complète, description (« Apartment Features »), inclusions, |
| 159 |
|
− galerie pleine taille.""" |
|
230 |
+ # -- fiche unité (/a-louer/<slug>) : galerie signée complète ----------------- |
|
231 |
+ def _fetch_detail(self, slug: str) -> dict: |
| 160 |
232 |
if self._fetched >= self.max_details: |
| 161 |
233 |
raise RuntimeError("budget de fiches unité atteint") |
| 162 |
234 |
self._fetched += 1 |
| 163 |
|
− html = self.get(url).text |
| 164 |
|
− soup = BeautifulSoup(html, "html.parser") |
| 165 |
|
− out: dict = {} |
| 166 |
|
− |
| 167 |
|
− details_bloc = soup.select_one(".specsheet .details") |
| 168 |
|
− if details_bloc: |
| 169 |
|
− # adresse civique complète : « 992 Av. Larivière, Rouyn-Noranda, |
| 170 |
|
− # QC J9X 4K5 » (premier <p> contenant la ville) |
| 171 |
|
− for p in details_bloc.find_all("p"): |
| 172 |
|
− t = re.sub(r"\s+", " ", p.get_text(" ", strip=True)) |
| 173 |
|
− if re.search(r"rouyn|noranda|évain|evain|,\s*QC", t, re.I) \ |
| 174 |
|
− and len(t) < 120 and not t.lower().startswith("address:"): |
| 175 |
|
− out["address"] = t |
| 176 |
|
− break |
| 177 |
|
− |
| 178 |
|
− # description libre (bloc anglais « Address / Availability / |
| 179 |
|
− # Apartment Features / Pets allowed… ») — champs bruts fidèles |
| 180 |
|
− best = "" |
| 181 |
|
− for p in details_bloc.find_all("p"): |
| 182 |
|
− t = p.get_text("\n", strip=True) |
| 183 |
|
− if len(t) > len(best): |
| 184 |
|
− best = t |
| 185 |
|
− if len(best) > 60: |
| 186 |
|
− out["description"] = re.sub(r"\n{2,}", "\n", |
| 187 |
|
− re.sub(r"[ \t]+", " ", best))[:1500] |
| 188 |
|
− |
| 189 |
|
− # galerie pleine taille (liens slick-colorbox) |
| 190 |
|
− out["images"] = list(dict.fromkeys( |
| 191 |
|
− a["href"] for a in details_bloc.select("a.slick-colorbox[href]") |
| 192 |
|
− if a["href"].startswith("http")))[:25] |
| 193 |
|
− |
| 194 |
|
− # « Inclus dans le prix » du panneau latéral |
| 195 |
|
− incl: list[str] = [] |
| 196 |
|
− for h5 in soup.select(".brown-panel h5"): |
| 197 |
|
− if "inclus" in strip_accents(h5.get_text(strip=True).lower()): |
| 198 |
|
− for sib in h5.find_next_siblings(): |
| 199 |
|
− if sib.name not in ("p", "ul", "li"): |
| 200 |
|
− break # fin de la section (bouton…) |
| 201 |
|
− for t in re.split(r"\s*[,;]\s*", |
| 202 |
|
− sib.get_text(" ", strip=True)): |
| 203 |
|
− t = re.sub(r"\s+", " ", t).strip() |
| 204 |
|
− if t and t not in incl: |
| 205 |
|
− incl.append(t) |
| 206 |
|
− out["included"] = incl[:10] |
| 207 |
|
− return out |
| 208 |
|
− |
| 209 |
|
− def _apply_detail(self, lst: Listing, d: dict) -> None: |
| 210 |
|
− """Reporte le payload (frais ou en cache) sur l'annonce.""" |
| 211 |
|
− if not d: |
| 212 |
|
− return |
| 213 |
|
− if d.get("address"): |
| 214 |
|
− lst.address = d["address"] |
| 215 |
|
− if d.get("description"): |
| 216 |
|
− lst.description = d["description"] |
| 217 |
|
− m = re.search(r"Pets allowed\s*:\s*([^\n]+)", d["description"], re.I) |
| 218 |
|
− if m: |
| 219 |
|
− pets = _pets_value(m.group(1)) |
| 220 |
|
− if pets: |
| 221 |
|
− lst.pets = pets |
| 222 |
|
− if d.get("included"): |
| 223 |
|
− lst.amenities = list(dict.fromkeys(lst.amenities + d["included"])) |
| 224 |
|
− if d.get("images"): |
| 225 |
|
− lst.images = d["images"] |
|
235 |
+ blob = _rsc_blob(self.get(f"{BASE}/a-louer/{slug}").text) |
|
236 |
+ photos = _extract_array(blob, "photos") or [] |
|
237 |
+ images = [p["url"] for p in photos |
|
238 |
+ if isinstance(p, dict) and str(p.get("url") or "").startswith("http")] |
|
239 |
+ return {"images": list(dict.fromkeys(images))[:25]} |