Expansion P3 — Estrie (6 connecteurs +24 annonces, 17 non-connectables)
terater (12) · alexcellence (4e Houzez, sous-classe Dynamic) · oxford · immeubles_pm (plogg vivant) · majestic (Granby) · lequerre (OBNL). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
43 changed files +20,001 −0
added
louka/connectors/alexcellence.py
+47 −0
@@ -0,0 +1,47 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/alexcellence.py : connecteur Immeubles Alexcellence | |
| 5 | +# (immeublesalexcellence.com) — apparts à Sherbrooke, secteurs Université de | |
| 6 | +# Sherbrooke et Mont-Bellevue (clientèle étudiante). WordPress + thème | |
| 7 | +# immobilier Houzez : 4e membre de la famille Houzez de l'Estrie — hérite de | |
| 8 | +# DynamicConnector (lesgestionsdynamic.com) et n'en redéfinit que les | |
| 9 | +# constantes et le crochet de disponibilité. Archive /appartements-a-louer/ | |
| 10 | +# paginée (cartes item-listing-wrap v6 avec data-listid, data-images, | |
| 11 | +# item-price « 1,010$/par mois »). Particularité locale : les étiquettes | |
| 12 | +# label-status portent à la fois le statut (« À LOUER » / « LOUÉ ») et la | |
| 13 | +# date de libération (« 1er août 2026 ») — les logements LOUÉ sont écartés, | |
| 14 | +# la date devient la disponibilité. Fiches détail Houzez sans bloc adresse | |
| 15 | +# structuré ni GPS : description + address.item-address seulement. | |
| 16 | +# ----------------------------------------------------------------------------- | |
| 17 | +from __future__ import annotations | |
| 18 | + | |
| 19 | +import re | |
| 20 | + | |
| 21 | +from .dynamic import DynamicConnector | |
| 22 | + | |
| 23 | + | |
| 24 | +class AlexcellenceConnector(DynamicConnector): | |
| 25 | + source_id = "alexcellence" | |
| 26 | + request_delay = 0.7 | |
| 27 | + max_pages = 6 | |
| 28 | + max_details = 40 | |
| 29 | + | |
| 30 | + BASE = "https://immeublesalexcellence.com" | |
| 31 | + LIST_PATH = "/appartements-a-louer/" | |
| 32 | + CITY_DEFAULT = "Sherbrooke" | |
| 33 | + | |
| 34 | + def _card_availability(self, card, title: str) -> str: | |
| 35 | + """Étiquettes label-status de la carte : « LOUÉ » fait écarter le | |
| 36 | + logement (le parent filtre toute disponibilité contenant « loué ») ; | |
| 37 | + sinon l'étiquette de date (« 1er août 2026 ») est la disponibilité.""" | |
| 38 | + labels = [re.sub(r"\s+", " ", el.get_text(" ", strip=True)).strip() | |
| 39 | + for el in card.select(".label-status")] | |
| 40 | + labels = [l for l in labels if l] | |
| 41 | + for label in labels: | |
| 42 | + if re.search(r"\blou[ée]s?\b", label, re.I): | |
| 43 | + return label # « LOUÉ » -> carte écartée | |
| 44 | + for label in labels: | |
| 45 | + if not re.fullmatch(r"[àa] louer", label, re.I): | |
| 46 | + return label # date de libération | |
| 47 | + return "" | |
added
louka/connectors/immeubles_pm.py
+198 −0
@@ -0,0 +1,198 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/immeubles_pm.py : connecteur Les Immeubles PM (lesimmeublespm.com) | |
| 5 | +# Parc familial de Sherbrooke (Fleurimont, Sherbrooke Est) — 6 immeubles. | |
| 6 | +# Plateforme « plogg solutions » (même famille que appartementsgranby.com / | |
| 7 | +# godbout, resté non connectable faute d'unités libres) : la page d'accueil | |
| 8 | +# rend côté serveur le bloc #listing-appart — une carte .box par immeuble | |
| 9 | +# AVEC UNITÉS LIBRES seulement, contenant un tableau Grandeur / Loyer | |
| 10 | +# (« à partir de 1475$ ») / Disponible + lien « Consulter la fiche ». | |
| 11 | +# La recherche (form-manager.php) est fermée aux robots, mais les fiches | |
| 12 | +# /appartement/<slug>/ restent servies : self.detail (cache BD) y lit le | |
| 13 | +# tableau complet (rue, ville, quartier, superficie, étage, salles de bain, | |
| 14 | +# état), la description (div.extrait) et la galerie. external_id = slug de | |
| 15 | +# la fiche (stable, un par unité-type d'immeuble). | |
| 16 | +# ----------------------------------------------------------------------------- | |
| 17 | +from __future__ import annotations | |
| 18 | + | |
| 19 | +import hashlib | |
| 20 | +import re | |
| 21 | + | |
| 22 | +from bs4 import BeautifulSoup | |
| 23 | + | |
| 24 | +from ..schema import Listing, normalize_unit_type, parse_price, strip_accents | |
| 25 | +from .base import BaseConnector | |
| 26 | + | |
| 27 | +BASE = "https://www.lesimmeublespm.com" | |
| 28 | + | |
| 29 | +_SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I) | |
| 30 | + | |
| 31 | + | |
| 32 | +def _clean_unit_type(raw: str) -> str: | |
| 33 | + ut = normalize_unit_type(raw or "") | |
| 34 | + if re.fullmatch(r"\d½\+?|6½\+|Studio|Loft|Chambre|Maison", ut or ""): | |
| 35 | + return ut | |
| 36 | + return "" | |
| 37 | + | |
| 38 | + | |
| 39 | +class ImmeublesPMConnector(BaseConnector): | |
| 40 | + source_id = "immeubles_pm" | |
| 41 | + request_delay = 0.7 | |
| 42 | + max_details = 25 # garde-fou fiches détail (vraies requêtes) | |
| 43 | + | |
| 44 | + def fetch(self) -> list[Listing]: | |
| 45 | + html = self.get(BASE + "/").text | |
| 46 | + soup = BeautifulSoup(html, "html.parser") | |
| 47 | + | |
| 48 | + listings: dict[str, Listing] = {} | |
| 49 | + listing_root = soup.select_one("#listing-appart") or soup | |
| 50 | + for box in listing_root.select("div.box"): | |
| 51 | + try: | |
| 52 | + self._parse_box(box, listings) | |
| 53 | + except Exception: | |
| 54 | + continue | |
| 55 | + | |
| 56 | + # fiches détail (cache BD) : adresse, superficie, étage, description… | |
| 57 | + self._fetched = 0 | |
| 58 | + for lst in listings.values(): | |
| 59 | + card_key = hashlib.sha1( | |
| 60 | + f"{lst.title}|{lst.price_label}|{lst.availability}" | |
| 61 | + .encode("utf-8")).hexdigest() | |
| 62 | + try: | |
| 63 | + payload = self.detail(lst.external_id, card_key, | |
| 64 | + lambda u=lst.url: self._fetch_detail(u)) | |
| 65 | + except Exception: | |
| 66 | + continue | |
| 67 | + self._apply_detail(lst, payload) | |
| 68 | + return list(listings.values()) | |
| 69 | + | |
| 70 | + # -- carte immeuble de l'accueil (tableau des unités libres) ----------------- | |
| 71 | + def _parse_box(self, box, listings: dict[str, Listing]) -> None: | |
| 72 | + title_el = box.select_one(".box-content-title") | |
| 73 | + building = title_el.get_text(" ", strip=True) if title_el else "" | |
| 74 | + quartier = "" | |
| 75 | + q_el = box.select_one(".box-content-quartier") | |
| 76 | + if q_el: | |
| 77 | + quartier = re.sub(r"^\s*Quartier\s*:?\s*", "", | |
| 78 | + q_el.get_text(" ", strip=True), flags=re.I).strip() | |
| 79 | + | |
| 80 | + photo = "" | |
| 81 | + ph_el = box.select_one(".photo-wrapper[style]") | |
| 82 | + if ph_el: | |
| 83 | + m = re.search(r"url\('([^']+)'\)", ph_el.get("style") or "") | |
| 84 | + if m: | |
| 85 | + photo = _SIZE_SUFFIX.sub("", m.group(1)) | |
| 86 | + | |
| 87 | + # tableau bureau (les blocs mobiles dupliquent les mêmes unités) | |
| 88 | + for row in box.select(".hide-on-mobile table tbody tr"): | |
| 89 | + link = row.select_one('a[href*="/appartement/"]') | |
| 90 | + if not link: | |
| 91 | + continue | |
| 92 | + url = link["href"] | |
| 93 | + m = re.search(r"/appartement/([^/?#]+)", url) | |
| 94 | + if not m: | |
| 95 | + continue | |
| 96 | + ext_id = m.group(1) | |
| 97 | + if ext_id in listings: | |
| 98 | + continue | |
| 99 | + cells = row.find_all("td") | |
| 100 | + grandeur = cells[0].get_text(" ", strip=True) if cells else "" | |
| 101 | + price_label = re.sub(r"\s+", " ", cells[1].get_text( | |
| 102 | + " ", strip=True)).strip() if len(cells) > 1 else "" | |
| 103 | + availability = re.sub(r"\s+", " ", cells[2].get_text( | |
| 104 | + " ", strip=True)).strip() if len(cells) > 2 else "" | |
| 105 | + if re.search(r"complet|lou[ée]\b", availability, re.I): | |
| 106 | + continue | |
| 107 | + | |
| 108 | + title = f"{grandeur} — {building}".strip(" —") | |
| 109 | + listings[ext_id] = Listing( | |
| 110 | + source=self.source_id, | |
| 111 | + external_id=ext_id, | |
| 112 | + url=url, | |
| 113 | + title=re.sub(r"\s+", " ", title).strip(), | |
| 114 | + address=building, # nom d'immeuble = adresse civique | |
| 115 | + sector=quartier, | |
| 116 | + city="Sherbrooke", # précisé par la fiche (champ Ville) | |
| 117 | + unit_type=_clean_unit_type(grandeur), | |
| 118 | + price=parse_price(price_label), | |
| 119 | + price_label=price_label, | |
| 120 | + availability=availability, | |
| 121 | + images=[photo] if photo else [], | |
| 122 | + ) | |
| 123 | + | |
| 124 | + # -- fiche /appartement/<slug>/ ----------------------------------------------- | |
| 125 | + def _fetch_detail(self, url: str) -> dict: | |
| 126 | + if self._fetched >= self.max_details: | |
| 127 | + raise RuntimeError("budget de fiches détail atteint") | |
| 128 | + self._fetched += 1 | |
| 129 | + html = self.get(url).text | |
| 130 | + soup = BeautifulSoup(html, "html.parser") | |
| 131 | + out: dict = {} | |
| 132 | + | |
| 133 | + # tableau infos-appart : paires <td.sub-title><b>Champ</b></td><td>valeur | |
| 134 | + pairs: dict[str, str] = {} | |
| 135 | + for row in soup.select(".infos-appart table tr"): | |
| 136 | + cells = row.find_all("td") | |
| 137 | + if len(cells) < 2: | |
| 138 | + continue | |
| 139 | + lab = strip_accents(cells[0].get_text(" ", strip=True).lower()) | |
| 140 | + val = re.sub(r"\s+", " ", cells[1].get_text(" ", strip=True)).strip() | |
| 141 | + val = re.sub(r"\bpi\s*2\b", "pi²", val) # « 1510 pi<sup>2</sup> » | |
| 142 | + if lab and val: | |
| 143 | + pairs[lab] = val | |
| 144 | + if pairs.get("rue"): | |
| 145 | + out["address"] = pairs["rue"] | |
| 146 | + if pairs.get("ville"): | |
| 147 | + out["city"] = pairs["ville"] | |
| 148 | + if pairs.get("quartier"): | |
| 149 | + out["sector"] = pairs["quartier"] | |
| 150 | + if pairs.get("grandeur"): | |
| 151 | + out["grandeur"] = pairs["grandeur"] | |
| 152 | + if pairs.get("etat"): | |
| 153 | + out["etat"] = pairs["etat"] | |
| 154 | + amenities = [] | |
| 155 | + for lab, label in [("superficie", "Superficie"), | |
| 156 | + ("etage", "Étage"), | |
| 157 | + ("nombre de salle de bain", "Salle(s) de bain")]: | |
| 158 | + if pairs.get(lab): | |
| 159 | + amenities.append(f"{label} : {pairs[lab]}") | |
| 160 | + out["amenities"] = amenities | |
| 161 | + | |
| 162 | + # description : bloc div.extrait (texte + liste d'inclusions) | |
| 163 | + ext = soup.select_one("div.extrait") | |
| 164 | + if ext: | |
| 165 | + txt = ext.get_text("\n", strip=True) | |
| 166 | + out["description"] = re.sub(r"[ \t]+", " ", txt).strip()[:1500] | |
| 167 | + | |
| 168 | + # galerie : photos jpg des téléversements (icônes png exclues) | |
| 169 | + images: list[str] = [] | |
| 170 | + for u in re.findall( | |
| 171 | + r"https://www\.lesimmeublespm\.com/wp-content/uploads/" | |
| 172 | + r"[^\"'()\s]+\.(?:jpg|jpeg|webp)", html, re.I): | |
| 173 | + u = _SIZE_SUFFIX.sub("", u) | |
| 174 | + if u not in images: | |
| 175 | + images.append(u) | |
| 176 | + out["images"] = images[:30] | |
| 177 | + return out | |
| 178 | + | |
| 179 | + def _apply_detail(self, lst: Listing, d: dict) -> None: | |
| 180 | + if not d: | |
| 181 | + return | |
| 182 | + if d.get("address"): | |
| 183 | + lst.address = d["address"] | |
| 184 | + if d.get("city"): | |
| 185 | + lst.city = d["city"] | |
| 186 | + if d.get("sector") and not lst.sector: | |
| 187 | + lst.sector = d["sector"] | |
| 188 | + if not lst.unit_type and d.get("grandeur"): | |
| 189 | + lst.unit_type = _clean_unit_type(d["grandeur"]) | |
| 190 | + if not lst.availability and d.get("etat"): | |
| 191 | + lst.availability = d["etat"] | |
| 192 | + if d.get("amenities"): | |
| 193 | + lst.amenities = list(dict.fromkeys(lst.amenities + d["amenities"])) | |
| 194 | + if d.get("description"): | |
| 195 | + lst.description = d["description"] | |
| 196 | + if d.get("images"): | |
| 197 | + merged = list(dict.fromkeys(d["images"] + lst.images)) | |
| 198 | + lst.images = merged[:30] | |
added
louka/connectors/lequerre.py
+122 −0
@@ -0,0 +1,122 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/lequerre.py : connecteur Habitations L'Équerre | |
| 5 | +# (habitationslequerre.com) — OBNL d'habitation de Sherbrooke (34 immeubles | |
| 6 | +# abordables, logement locatif privé consultable, pas du HLM). WordPress, | |
| 7 | +# mais la page /immeubles/ est un gabarit maison (Tailwind) rendu serveur : | |
| 8 | +# une <section class="building-section"> par immeuble vedette, dont le | |
| 9 | +# data-category vaut « disponibles » quand l'immeuble a des unités à louer — | |
| 10 | +# badge « DISPONIBLE MAINTENANT - 735$ / mois », h2 (type de logements), | |
| 11 | +# h3 (adresse civique), paragraphe descriptif et visuel en arrière-plan. | |
| 12 | +# Seules les sections « disponibles » deviennent des annonces (granularité | |
| 13 | +# immeuble : le site ne publie pas d'unités individuelles datées). | |
| 14 | +# external_id = version normalisée de l'adresse (stable). | |
| 15 | +# ----------------------------------------------------------------------------- | |
| 16 | +from __future__ import annotations | |
| 17 | + | |
| 18 | +import re | |
| 19 | +import unicodedata | |
| 20 | + | |
| 21 | +from bs4 import BeautifulSoup | |
| 22 | + | |
| 23 | +from ..schema import Listing, normalize_unit_type, parse_price | |
| 24 | +from .base import BaseConnector | |
| 25 | + | |
| 26 | +BASE = "https://habitationslequerre.com" | |
| 27 | +LIST_URL = f"{BASE}/immeubles/" | |
| 28 | + | |
| 29 | + | |
| 30 | +def _slugify(text: str) -> str: | |
| 31 | + t = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode() | |
| 32 | + t = re.sub(r"[^a-z0-9]+", "-", t.lower()).strip("-") | |
| 33 | + return t or "immeuble" | |
| 34 | + | |
| 35 | + | |
| 36 | +def _clean_unit_type(raw: str) -> str: | |
| 37 | + if re.search(r"\bstudios?\b", raw or "", re.I): | |
| 38 | + return "Studio" | |
| 39 | + ut = normalize_unit_type(raw or "") | |
| 40 | + if re.fullmatch(r"\d½\+?|6½\+|Studio|Loft|Chambre|Maison", ut or ""): | |
| 41 | + return ut | |
| 42 | + return "" | |
| 43 | + | |
| 44 | + | |
| 45 | +class LequerreConnector(BaseConnector): | |
| 46 | + source_id = "lequerre" | |
| 47 | + request_delay = 0.7 | |
| 48 | + | |
| 49 | + def fetch(self) -> list[Listing]: | |
| 50 | + html = self.get(LIST_URL).text | |
| 51 | + soup = BeautifulSoup(html, "html.parser") | |
| 52 | + | |
| 53 | + listings: dict[str, Listing] = {} | |
| 54 | + for sec in soup.select("section.building-section"): | |
| 55 | + cats = (sec.get("data-category") or "").lower() | |
| 56 | + if "disponibles" not in cats: | |
| 57 | + continue # immeuble sans unité à louer actuellement | |
| 58 | + try: | |
| 59 | + lst = self._parse_section(sec) | |
| 60 | + except Exception: | |
| 61 | + continue | |
| 62 | + if lst and lst.external_id not in listings: | |
| 63 | + listings[lst.external_id] = lst | |
| 64 | + return list(listings.values()) | |
| 65 | + | |
| 66 | + def _parse_section(self, sec) -> Listing | None: | |
| 67 | + h2 = sec.select_one("h2") | |
| 68 | + h3 = sec.select_one("h3") | |
| 69 | + title = re.sub(r"\s+", " ", h2.get_text(" ", strip=True)).strip() \ | |
| 70 | + if h2 else "" | |
| 71 | + address_full = re.sub(r"\s+", " ", h3.get_text(" ", strip=True)).strip() \ | |
| 72 | + if h3 else "" | |
| 73 | + if not (title or address_full): | |
| 74 | + return None | |
| 75 | + | |
| 76 | + # « 14, rue Jean-Maurice, Sherbrooke » -> adresse + ville | |
| 77 | + address, city = address_full, "" | |
| 78 | + m = re.match(r"^(.*),\s*(Sherbrooke)\s*$", address_full, re.I) | |
| 79 | + if m: | |
| 80 | + address, city = m.group(1).strip(), m.group(2) | |
| 81 | + | |
| 82 | + # badge « DISPONIBLE MAINTENANT - 735$ / mois » | |
| 83 | + badge = "" | |
| 84 | + for div in sec.find_all("div"): | |
| 85 | + t = div.get_text(" ", strip=True) | |
| 86 | + if re.match(r"^\s*DISPONIBLE", t or "", re.I) and len(t) < 90: | |
| 87 | + badge = re.sub(r"\s+", " ", t).strip() | |
| 88 | + break | |
| 89 | + availability, price_label = badge, "" | |
| 90 | + if "-" in badge: | |
| 91 | + availability, _, price_label = (p.strip() for p in | |
| 92 | + badge.partition("-")) | |
| 93 | + | |
| 94 | + # paragraphe descriptif | |
| 95 | + description = "" | |
| 96 | + p = sec.select_one("p") | |
| 97 | + if p: | |
| 98 | + description = re.sub(r"\s+", " ", | |
| 99 | + p.get_text(" ", strip=True)).strip()[:1500] | |
| 100 | + | |
| 101 | + # visuel : background-image de la colonne photo | |
| 102 | + images: list[str] = [] | |
| 103 | + bg = sec.select_one("[style*=background-image]") | |
| 104 | + if bg: | |
| 105 | + m_img = re.search(r"url\('([^']+)'\)", bg.get("style") or "") | |
| 106 | + if m_img and m_img.group(1).startswith("http"): | |
| 107 | + images = [m_img.group(1)] | |
| 108 | + | |
| 109 | + return Listing( | |
| 110 | + source=self.source_id, | |
| 111 | + external_id=_slugify(address or title), | |
| 112 | + url=LIST_URL, | |
| 113 | + title=title or address_full, | |
| 114 | + address=address, | |
| 115 | + city=city, | |
| 116 | + unit_type=_clean_unit_type(title), | |
| 117 | + price=parse_price(price_label), | |
| 118 | + price_label=price_label, | |
| 119 | + availability=availability, | |
| 120 | + description=description, | |
| 121 | + images=images, | |
| 122 | + ) | |
added
louka/connectors/majestic.py
+140 −0
@@ -0,0 +1,140 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/majestic.py : connecteur Le Majestic du Lac Boivin | |
| 5 | +# (majesticlacboivin.ca) — 1 complexe de condos locatifs haut de gamme à | |
| 6 | +# Granby (150 rue de la Lobélie, adresse affichée sur la page « Nous | |
| 7 | +# joindre », schema.org PostalAddress). Site custom (non-WordPress) : la | |
| 8 | +# page /fr/condos-locatifs.html rend côté serveur la liste des unités | |
| 9 | +# libres (div.uniteItem[data-id] dans .listeResultatUnite) avec unité, | |
| 10 | +# étage, chambres, salles de bain, superficie, loyer, disponibilité, plan | |
| 11 | +# PDF et bouton « Info » — les unités réservées portent span.nonDispo | |
| 12 | +# (« Réservé ») et sont écartées. Les inclusions/avantages de l'immeuble | |
| 13 | +# (listes .services-liste) complètent les commodités. external_id = | |
| 14 | +# data-id de l'unité (identifiant interne stable). Le loyer est parfois | |
| 15 | +# affiché sans « $ » (« 1795 ») : la colonne étant explicitement « Loyer », | |
| 16 | +# le montant est extrait tel quel. robots.txt : /inc/, /Gestion/ et /auto/ | |
| 17 | +# interdits — la page des condos est permise. | |
| 18 | +# ----------------------------------------------------------------------------- | |
| 19 | +from __future__ import annotations | |
| 20 | + | |
| 21 | +import re | |
| 22 | + | |
| 23 | +from bs4 import BeautifulSoup | |
| 24 | + | |
| 25 | +from ..schema import Listing | |
| 26 | +from .base import BaseConnector | |
| 27 | + | |
| 28 | +BASE = "https://www.majesticlacboivin.ca" | |
| 29 | +LIST_URL = f"{BASE}/fr/condos-locatifs.html" | |
| 30 | + | |
| 31 | +# adresse du complexe, telle qu'affichée sur /fr/nous-joindre.html | |
| 32 | +ADDRESS = "150 rue de la Lobélie" | |
| 33 | +CITY = "Granby" | |
| 34 | + | |
| 35 | + | |
| 36 | +def _col_value(item, label: str) -> str: | |
| 37 | + """Valeur d'une colonne uniteItemCol dont le titre est `label`.""" | |
| 38 | + for col in item.select(".uniteItemCol"): | |
| 39 | + t = col.select_one(".uniteItemTitre") | |
| 40 | + if t and t.get_text(" ", strip=True).lower() == label.lower(): | |
| 41 | + val = col.get_text(" ", strip=True) | |
| 42 | + val = re.sub(re.escape(t.get_text(" ", strip=True)), "", val, | |
| 43 | + count=1).strip() | |
| 44 | + val = re.sub(r"\s+", " ", val) | |
| 45 | + return re.sub(r"\bpi\s*2\b", "pi²", val) # « 735 pi<sup>2</sup> » | |
| 46 | + return "" | |
| 47 | + | |
| 48 | + | |
| 49 | +class MajesticConnector(BaseConnector): | |
| 50 | + source_id = "majestic" | |
| 51 | + request_delay = 0.7 | |
| 52 | + | |
| 53 | + def fetch(self) -> list[Listing]: | |
| 54 | + html = self.get(LIST_URL).text | |
| 55 | + soup = BeautifulSoup(html, "html.parser") | |
| 56 | + | |
| 57 | + # inclusions et avantages de l'immeuble (commodités communes) | |
| 58 | + building_amenities: list[str] = [] | |
| 59 | + for ul in soup.select("ul.services-liste"): | |
| 60 | + for li in ul.select("li.services-liste_item"): | |
| 61 | + t = re.sub(r"\s+", " ", li.get_text(" ", strip=True)).strip() | |
| 62 | + if t and t not in building_amenities: | |
| 63 | + building_amenities.append(t) | |
| 64 | + | |
| 65 | + listings: list[Listing] = [] | |
| 66 | + for item in soup.select(".listeResultatUnite .uniteItem[data-id]"): | |
| 67 | + try: | |
| 68 | + lst = self._parse_unit(item, building_amenities) | |
| 69 | + except Exception: | |
| 70 | + continue | |
| 71 | + if lst and lst.external_id not in {l.external_id for l in listings}: | |
| 72 | + listings.append(lst) | |
| 73 | + return listings | |
| 74 | + | |
| 75 | + def _parse_unit(self, item, building_amenities: list[str]) -> Listing | None: | |
| 76 | + # unités réservées : bouton remplacé par span.nonDispo « Réservé » | |
| 77 | + if item.select_one(".nonDispo"): | |
| 78 | + return None | |
| 79 | + ext_id = str(item.get("data-id") or "").strip() | |
| 80 | + if not ext_id: | |
| 81 | + return None | |
| 82 | + | |
| 83 | + # « Unité 308 » : première colonne, sans titre | |
| 84 | + unit_no = "" | |
| 85 | + first = item.select_one(".uniteItemCol.c2 .uniteItemValeur") | |
| 86 | + if first: | |
| 87 | + unit_no = re.sub(r"\s+", " ", first.get_text(" ", strip=True)).strip() | |
| 88 | + | |
| 89 | + etage = _col_value(item, "Étage") | |
| 90 | + chambres = _col_value(item, "Chambres") | |
| 91 | + sdb = _col_value(item, "Salles de bain") | |
| 92 | + superficie = _col_value(item, "Superficie") # « 735 pi 2 » | |
| 93 | + loyer = _col_value(item, "Loyer") # « 1695 $ » / « 1795 » | |
| 94 | + dispo = _col_value(item, "Disponibilité") | |
| 95 | + dispo = re.sub(r"\b(\d+)\s+(er|re|e)\b", r"\1\2", dispo) # « 1 er » -> « 1er » | |
| 96 | + | |
| 97 | + # colonne « Loyer » explicite : montant extrait même sans « $ » | |
| 98 | + price = None | |
| 99 | + m = re.search(r"(\d[\d\s]{2,7})", loyer.replace(",", " ")) | |
| 100 | + if m: | |
| 101 | + try: | |
| 102 | + val = float(m.group(1).replace(" ", "")) | |
| 103 | + if 100 <= val <= 20000: | |
| 104 | + price = val | |
| 105 | + except ValueError: | |
| 106 | + pass | |
| 107 | + | |
| 108 | + amenities = [] | |
| 109 | + for label, val in [("Chambres", chambres), ("Salle(s) de bain", sdb), | |
| 110 | + ("Superficie", superficie), ("Étage", etage)]: | |
| 111 | + if val: | |
| 112 | + amenities.append(f"{label} : {val}") | |
| 113 | + amenities += [a for a in building_amenities if a not in amenities] | |
| 114 | + | |
| 115 | + area = None | |
| 116 | + m_area = re.search(r"(\d{3,5})\s*pi", superficie) | |
| 117 | + if m_area: | |
| 118 | + area = float(m_area.group(1)) | |
| 119 | + | |
| 120 | + plan = item.select_one("a.planPDF[href]") | |
| 121 | + details = {} | |
| 122 | + if plan: | |
| 123 | + details["plan_pdf"] = f"{BASE}/{plan['href'].lstrip('/')}" | |
| 124 | + | |
| 125 | + title = f"Le Majestic du Lac Boivin — {unit_no}" if unit_no \ | |
| 126 | + else "Le Majestic du Lac Boivin" | |
| 127 | + return Listing( | |
| 128 | + source=self.source_id, | |
| 129 | + external_id=ext_id, | |
| 130 | + url=LIST_URL, | |
| 131 | + title=title, | |
| 132 | + address=ADDRESS, | |
| 133 | + city=CITY, | |
| 134 | + price=price, | |
| 135 | + price_label=loyer, | |
| 136 | + availability=dispo, | |
| 137 | + area_sqft=area, | |
| 138 | + amenities=amenities[:25], | |
| 139 | + details=details, | |
| 140 | + ) | |
added
louka/connectors/oxford.py
+158 −0
@@ -0,0 +1,158 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/oxford.py : connecteur Appartements Oxford (appartoxford.com) | |
| 5 | +# Complexe locatif de Lennoxville (Sherbrooke), près de l'Université | |
| 6 | +# Bishop's. WordPress + Elementor : une fiche par TYPE d'unité (2½ tout | |
| 7 | +# inclus, 3½, 4½, 5½), listées dans le menu « Logements » de la page | |
| 8 | +# d'accueil. Chaque fiche affiche le prix (« À partir de 760$ / par mois ») | |
| 9 | +# et, seulement quand des unités sont libres, un encart de disponibilité | |
| 10 | +# (« 1 unité disponible 1er octobre 2026 », widget Elementor à visibilité | |
| 11 | +# conditionnelle) : les fiches SANS cet encart ne sont pas des annonces | |
| 12 | +# courantes et sont écartées. Caractéristiques (listes à pictos), description | |
| 13 | +# et galerie extraites de la fiche. Peu de requêtes (accueil + ~4 fiches) : | |
| 14 | +# pas de cache détail nécessaire — la disponibilité doit être relue à chaque | |
| 15 | +# synchronisation. | |
| 16 | +# ----------------------------------------------------------------------------- | |
| 17 | +from __future__ import annotations | |
| 18 | + | |
| 19 | +import re | |
| 20 | + | |
| 21 | +from urllib.parse import unquote | |
| 22 | + | |
| 23 | +from bs4 import BeautifulSoup | |
| 24 | + | |
| 25 | +from ..schema import Listing, normalize_unit_type | |
| 26 | +from .base import BaseConnector | |
| 27 | + | |
| 28 | +BASE = "https://appartoxford.com" | |
| 29 | + | |
| 30 | +# variantes redimensionnées WordPress -> pleine taille | |
| 31 | +_SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I) | |
| 32 | + | |
| 33 | +# « 1 unité disponible 1er octobre 2026 » / « 2 unités disponibles maintenant » | |
| 34 | +_AVAIL_RE = re.compile(r"(\d+|une?)\s+unit[ée]s?\s+disponibles?\b", re.I) | |
| 35 | +_NO_AVAIL_RE = re.compile(r"aucune\s+unit[ée]", re.I) | |
| 36 | + | |
| 37 | +# bruit d'en-tête dans les listes à pictos (coordonnées du site) | |
| 38 | +_HEADER_NOISE_RE = re.compile(r"\+?1?\s?8\d{2}[-\s]\d{3}|Lennoxville,\s*Sherbrooke", | |
| 39 | + re.I) | |
| 40 | + | |
| 41 | +_MAX_FICHES = 12 # garde-fou (le menu compte ~4 fiches) | |
| 42 | + | |
| 43 | + | |
| 44 | +def _clean_unit_type(raw: str) -> str: | |
| 45 | + ut = normalize_unit_type(raw or "") | |
| 46 | + if re.fullmatch(r"\d½\+?|6½\+|Studio|Loft|Chambre|Maison", ut or ""): | |
| 47 | + return ut | |
| 48 | + return "" | |
| 49 | + | |
| 50 | + | |
| 51 | +class OxfordConnector(BaseConnector): | |
| 52 | + source_id = "oxford" | |
| 53 | + request_delay = 0.7 | |
| 54 | + | |
| 55 | + def fetch(self) -> list[Listing]: | |
| 56 | + home = self.get(BASE + "/").text | |
| 57 | + soup = BeautifulSoup(home, "html.parser") | |
| 58 | + | |
| 59 | + # fiches du menu « Logements » : liens /appartement/<slug>/ | |
| 60 | + urls: list[str] = [] | |
| 61 | + for a in soup.select('a[href*="/appartement/"]'): | |
| 62 | + url = (a.get("href") or "").split("#")[0] | |
| 63 | + if not url.startswith("http"): | |
| 64 | + url = BASE + url | |
| 65 | + if url.rstrip("/") not in [u.rstrip("/") for u in urls]: | |
| 66 | + urls.append(url) | |
| 67 | + | |
| 68 | + listings: dict[str, Listing] = {} | |
| 69 | + for url in urls[:_MAX_FICHES]: | |
| 70 | + try: | |
| 71 | + lst = self._parse_fiche(url) | |
| 72 | + except Exception: | |
| 73 | + continue | |
| 74 | + if lst and lst.external_id not in listings: | |
| 75 | + listings[lst.external_id] = lst | |
| 76 | + return list(listings.values()) | |
| 77 | + | |
| 78 | + # -- fiche type d'unité ------------------------------------------------------- | |
| 79 | + def _parse_fiche(self, url: str) -> Listing | None: | |
| 80 | + resp = self.get(url) | |
| 81 | + url = resp.url or url # les anciens slugs redirigent (301) | |
| 82 | + m = re.search(r"/appartement/([^/?#]+)", url) | |
| 83 | + if not m: | |
| 84 | + return None | |
| 85 | + # slug décodé + minuscule : « tres-grand-4½-a-louer » (dédoublonne | |
| 86 | + # les variantes d'encodage %C2%BD / %c2%bd) | |
| 87 | + ext_id = unquote(m.group(1)).lower() | |
| 88 | + | |
| 89 | + html = resp.text | |
| 90 | + soup = BeautifulSoup(html, "html.parser") | |
| 91 | + text = soup.get_text(" ", strip=True) | |
| 92 | + | |
| 93 | + # encart de disponibilité : widget texte Elementor à visibilité | |
| 94 | + # conditionnelle, présent seulement si des unités sont libres | |
| 95 | + availability = "" | |
| 96 | + for w in soup.select(".elementor-widget-text-editor"): | |
| 97 | + t = re.sub(r"\s+", " ", w.get_text(" ", strip=True)).strip() | |
| 98 | + if _AVAIL_RE.match(t) and len(t) < 90: | |
| 99 | + availability = t | |
| 100 | + break | |
| 101 | + if not availability or _NO_AVAIL_RE.search(availability): | |
| 102 | + return None # pas d'unité libre = pas d'annonce | |
| 103 | + | |
| 104 | + h1 = soup.select_one("h1") | |
| 105 | + title = re.sub(r"\s+", " ", h1.get_text(" ", strip=True)).strip() \ | |
| 106 | + if h1 else ext_id | |
| 107 | + | |
| 108 | + # prix : « À partir de | 1000$ / par mois » (widgets bannière) | |
| 109 | + price_label = "" | |
| 110 | + m_price = re.search( | |
| 111 | + r"À partir de\s*(\d[\d\s,]*\$\s*(?:/\s*par mois|/\s*mois)?)", text) | |
| 112 | + if m_price: | |
| 113 | + price_label = "À partir de " + re.sub( | |
| 114 | + r"\s+", " ", m_price.group(1)).strip() | |
| 115 | + | |
| 116 | + # caractéristiques : listes à pictos Elementor (sans les coordonnées | |
| 117 | + # affichées dans l'en-tête du site) | |
| 118 | + amenities: list[str] = [] | |
| 119 | + for li in soup.select(".elementor-icon-list-text"): | |
| 120 | + t = re.sub(r"\s+", " ", li.get_text(" ", strip=True)).strip() | |
| 121 | + if (t and len(t) < 90 and t not in amenities | |
| 122 | + and not _HEADER_NOISE_RE.search(t)): | |
| 123 | + amenities.append(t) | |
| 124 | + | |
| 125 | + # description : contenu de l'article (widget theme-post-content de la | |
| 126 | + # section Caractéristiques) | |
| 127 | + description = "" | |
| 128 | + for w in soup.select(".elementor-widget-theme-post-content, " | |
| 129 | + ".elementor-widget-text-editor"): | |
| 130 | + t = re.sub(r"\s+", " ", w.get_text(" ", strip=True)).strip() | |
| 131 | + if len(t) > 150 and not _AVAIL_RE.match(t): | |
| 132 | + description = t[:1500] | |
| 133 | + break | |
| 134 | + | |
| 135 | + # galerie : photos des téléversements (logos/pictos exclus) | |
| 136 | + images: list[str] = [] | |
| 137 | + for u in re.findall( | |
| 138 | + r"https://appartoxford\.com/wp-content/uploads/[^\"'()\s]+" | |
| 139 | + r"\.(?:jpg|jpeg|webp)", html, re.I): | |
| 140 | + u = _SIZE_SUFFIX.sub("", u) | |
| 141 | + if u not in images and not re.search(r"logo|icon", u, re.I): | |
| 142 | + images.append(u) | |
| 143 | + | |
| 144 | + return Listing( | |
| 145 | + source=self.source_id, | |
| 146 | + external_id=ext_id, | |
| 147 | + url=url, | |
| 148 | + title=title, | |
| 149 | + address="", | |
| 150 | + sector="Lennoxville", # affiché en en-tête du site | |
| 151 | + city="Sherbrooke", | |
| 152 | + unit_type=_clean_unit_type(title), | |
| 153 | + price_label=price_label, | |
| 154 | + availability=availability, | |
| 155 | + description=description, | |
| 156 | + amenities=amenities[:25], | |
| 157 | + images=images[:30], | |
| 158 | + ) | |
added
louka/connectors/terater.py
+243 −0
@@ -0,0 +1,243 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/terater.py : connecteur Les Constructions Tèratèr (terater.ca) | |
| 5 | +# Constructeur-locateur de l'Estrie : Val-Joli/Windsor, Sherbrooke (Nord, | |
| 6 | +# Fleurimont, Seyval), Orford, Cookshire-Eaton. WordPress + thème enfant | |
| 7 | +# Avada avec blocs maison (même famille de balisage que morin.py) : archive | |
| 8 | +# /a-louer/ rendue serveur — cartes div.landing_modele_block_item complètes | |
| 9 | +# (bandeau de disponibilité coloré, type « 3 ½ », adresse, secteur/ville, | |
| 10 | +# chambres/salle de bain/étage/stationnement/animaux, prix « par mois »). | |
| 11 | +# La fiche (via self.detail, cache BD) ajoute la superficie, la liste des | |
| 12 | +# inclusions et la galerie photos. Les logements loués n'apparaissent pas | |
| 13 | +# dans l'archive (bandeau « Loué » absent au moment de l'écriture) mais on | |
| 14 | +# filtre par prudence. | |
| 15 | +# ----------------------------------------------------------------------------- | |
| 16 | +from __future__ import annotations | |
| 17 | + | |
| 18 | +import hashlib | |
| 19 | +import re | |
| 20 | + | |
| 21 | +from bs4 import BeautifulSoup | |
| 22 | + | |
| 23 | +from ..schema import Listing, normalize_unit_type, parse_price | |
| 24 | +from .base import BaseConnector | |
| 25 | + | |
| 26 | +BASE = "https://terater.ca" | |
| 27 | +LIST_URL = f"{BASE}/a-louer/" | |
| 28 | + | |
| 29 | +# variantes redimensionnées WordPress (-600x397.jpg) -> pleine taille | |
| 30 | +_SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I) | |
| 31 | + | |
| 32 | +# villes réelles desservies (jamais devinées) + secteurs de Sherbrooke connus | |
| 33 | +_KNOWN_CITIES = [ | |
| 34 | + "Sherbrooke", "Windsor", "Val-Joli", "Orford", "Cookshire-Eaton", | |
| 35 | + "East Angus", "Magog", "Richmond", "Ascot Corner", "Saint-Denis-de-Brompton", | |
| 36 | +] | |
| 37 | +_SHERBROOKE_SECTORS = [ | |
| 38 | + "Fleurimont", "Rock Forest", "Lennoxville", "Mont-Bellevue", | |
| 39 | + "Jacques-Cartier", "Brompton", "Saint-Élie", "St-Élie", | |
| 40 | +] | |
| 41 | + | |
| 42 | +# espaces non résidentiels dans le champ « type » de la carte | |
| 43 | +_NON_RESIDENTIAL_RE = re.compile( | |
| 44 | + r"commercial|bureau|local|garage|entrep[oô]t|rangement", re.I) | |
| 45 | + | |
| 46 | + | |
| 47 | +def _city_sector(raw: str) -> tuple[str, str]: | |
| 48 | + """« Val-Joli » -> (Val-Joli, '') ; « Nord de Sherbrooke » -> | |
| 49 | + (Sherbrooke, Nord de Sherbrooke) ; « Fleurimont » -> (Sherbrooke, | |
| 50 | + Fleurimont). Ville vide si le secteur est inconnu (rien d'inventé).""" | |
| 51 | + t = re.sub(r"\s+", " ", raw or "").strip() | |
| 52 | + if not t: | |
| 53 | + return "", "" | |
| 54 | + for city in _KNOWN_CITIES: | |
| 55 | + if t.lower() == city.lower(): | |
| 56 | + return city, "" | |
| 57 | + if re.search(r"\bsherbrooke\b", t, re.I): | |
| 58 | + return "Sherbrooke", t | |
| 59 | + for sec in _SHERBROOKE_SECTORS: | |
| 60 | + if t.lower() == sec.lower(): | |
| 61 | + return "Sherbrooke", t | |
| 62 | + return "", t | |
| 63 | + | |
| 64 | + | |
| 65 | +def _clean_unit_type(raw: str) -> str: | |
| 66 | + """normalize_unit_type seulement si le motif est net (« 3 ½ », studio…).""" | |
| 67 | + ut = normalize_unit_type(raw or "") | |
| 68 | + if re.fullmatch(r"\d½\+?|6½\+|Studio|Loft|Chambre|Maison", ut or ""): | |
| 69 | + return ut | |
| 70 | + return "" | |
| 71 | + | |
| 72 | + | |
| 73 | +class TeraterConnector(BaseConnector): | |
| 74 | + source_id = "terater" | |
| 75 | + request_delay = 0.7 | |
| 76 | + max_details = 30 # garde-fou fiches détail (vraies requêtes) | |
| 77 | + | |
| 78 | + def fetch(self) -> list[Listing]: | |
| 79 | + html = self.get(LIST_URL).text | |
| 80 | + soup = BeautifulSoup(html, "html.parser") | |
| 81 | + | |
| 82 | + listings: dict[str, Listing] = {} | |
| 83 | + for card in soup.select("div.landing_modele_block_item"): | |
| 84 | + try: | |
| 85 | + self._parse_card(card, listings) | |
| 86 | + except Exception: | |
| 87 | + continue | |
| 88 | + | |
| 89 | + # fiches détail (cache BD) : superficie, inclusions, galerie | |
| 90 | + self._fetched = 0 | |
| 91 | + for lst in listings.values(): | |
| 92 | + card_key = hashlib.sha1( | |
| 93 | + f"{lst.title}|{lst.price_label}|{lst.availability}" | |
| 94 | + .encode("utf-8")).hexdigest() | |
| 95 | + try: | |
| 96 | + payload = self.detail(lst.external_id, card_key, | |
| 97 | + lambda u=lst.url: self._fetch_detail(u)) | |
| 98 | + except Exception: | |
| 99 | + continue | |
| 100 | + self._apply_detail(lst, payload) | |
| 101 | + return list(listings.values()) | |
| 102 | + | |
| 103 | + # -- carte de l'archive /a-louer/ ------------------------------------------ | |
| 104 | + def _parse_card(self, card, listings: dict[str, Listing]) -> None: | |
| 105 | + link = card.select_one("a.landing_modele_block_item_link[href]") | |
| 106 | + if not link: | |
| 107 | + return | |
| 108 | + url = link["href"] | |
| 109 | + m = re.search(r"/a-louer/([^/]+)/?", url) | |
| 110 | + if not m: | |
| 111 | + return | |
| 112 | + ext_id = m.group(1) | |
| 113 | + if ext_id in listings or "formulaire" in ext_id: | |
| 114 | + return | |
| 115 | + | |
| 116 | + # bandeau(x) de disponibilité : « Libre immédiatement », « Juillet | |
| 117 | + # 2026 »… — on écarte tout logement marqué loué | |
| 118 | + tags = [re.sub(r"\s+", " ", t.get_text(" ", strip=True)).strip() | |
| 119 | + for t in card.select(".landing_modele_item_tag_item")] | |
| 120 | + tags = [t for t in tags if t] | |
| 121 | + if any(re.search(r"\blou[ée]s?\b", t, re.I) for t in tags): | |
| 122 | + return | |
| 123 | + availability = tags[0] if tags else "" | |
| 124 | + | |
| 125 | + # type d'espace (« Appartement », « Maison »…) : écarter le non | |
| 126 | + # résidentiel (locaux commerciaux, garages…) | |
| 127 | + category = "" | |
| 128 | + cat_el = card.select_one(".landing_modele_block_item_type") | |
| 129 | + if cat_el: | |
| 130 | + category = cat_el.get_text(" ", strip=True) | |
| 131 | + if _NON_RESIDENTIAL_RE.search(category): | |
| 132 | + return | |
| 133 | + | |
| 134 | + piece_el = card.select_one(".landing_modele_piece") | |
| 135 | + piece = piece_el.get_text(" ", strip=True) if piece_el else "" | |
| 136 | + | |
| 137 | + addr_el = card.select_one(".landing_modele_block_item_adresse") | |
| 138 | + address = re.sub(r"\s+", " ", addr_el.get_text(" ", strip=True)).strip() \ | |
| 139 | + if addr_el else "" | |
| 140 | + | |
| 141 | + sect_el = card.select_one(".landing_modele_block_item_secteur div") | |
| 142 | + city, sector = _city_sector( | |
| 143 | + sect_el.get_text(" ", strip=True) if sect_el else "") | |
| 144 | + | |
| 145 | + # prix : « 1 075$ » + « par mois » | |
| 146 | + price_el = card.select_one(".landing_modele_price") | |
| 147 | + after_el = card.select_one(".landing_modele_price_after") | |
| 148 | + price_label = "" | |
| 149 | + if price_el: | |
| 150 | + price_label = price_el.get_text(" ", strip=True) | |
| 151 | + if after_el: | |
| 152 | + price_label += " " + after_el.get_text(" ", strip=True) | |
| 153 | + | |
| 154 | + # pictos : chambres / salle de bain / étage / stationnement / animaux | |
| 155 | + amenities: list[str] = [] | |
| 156 | + for cls, label in [("landing_modele_item_bed", "Chambres"), | |
| 157 | + ("landing_modele_item_bath", "Salle(s) de bain"), | |
| 158 | + ("landing_modele_etage", "Étage"), | |
| 159 | + ("landing_modele_stationnement", "Stationnement")]: | |
| 160 | + el = card.select_one(f".{cls} span") | |
| 161 | + if el: | |
| 162 | + val = re.sub(r"\s+", " ", el.get_text(" ", strip=True)).strip() | |
| 163 | + if val: | |
| 164 | + amenities.append(f"{label} : {val}") | |
| 165 | + animals_el = card.select_one(".landing_modele_animaux .animaux_note") | |
| 166 | + if animals_el: | |
| 167 | + note = animals_el.get_text(" ", strip=True) | |
| 168 | + if note: | |
| 169 | + amenities.append(note) # « Animaux accepter » (texte du site) | |
| 170 | + | |
| 171 | + # visuel de la carte (background-image) | |
| 172 | + images: list[str] = [] | |
| 173 | + img_el = card.select_one(".landing_modele_block_item_img_bg[style]") | |
| 174 | + if img_el: | |
| 175 | + m_img = re.search(r"url\('([^']+)'\)", img_el.get("style") or "") | |
| 176 | + if m_img: | |
| 177 | + images = [_SIZE_SUFFIX.sub("", m_img.group(1))] | |
| 178 | + | |
| 179 | + title = " ".join(p for p in (piece, category, "—", address) if p) \ | |
| 180 | + if address else (piece or category) | |
| 181 | + details = {"category": category} if category else {} | |
| 182 | + | |
| 183 | + listings[ext_id] = Listing( | |
| 184 | + source=self.source_id, | |
| 185 | + external_id=ext_id, | |
| 186 | + url=url, | |
| 187 | + title=re.sub(r"\s+", " ", title).strip(), | |
| 188 | + address=address, | |
| 189 | + sector=sector, | |
| 190 | + city=city, | |
| 191 | + unit_type=_clean_unit_type(piece), | |
| 192 | + price=parse_price(price_label), | |
| 193 | + price_label=price_label, | |
| 194 | + availability=availability, | |
| 195 | + amenities=amenities, | |
| 196 | + details=details, | |
| 197 | + images=images, | |
| 198 | + ) | |
| 199 | + | |
| 200 | + # -- fiche détail ----------------------------------------------------------- | |
| 201 | + def _fetch_detail(self, url: str) -> dict: | |
| 202 | + """Superficie, inclusions et galerie de la fiche single_modele_*.""" | |
| 203 | + if self._fetched >= self.max_details: | |
| 204 | + raise RuntimeError("budget de fiches détail atteint") | |
| 205 | + self._fetched += 1 | |
| 206 | + html = self.get(url).text | |
| 207 | + soup = BeautifulSoup(html, "html.parser") | |
| 208 | + out: dict = {} | |
| 209 | + | |
| 210 | + sup_el = soup.select_one(".single_modele_superficie") | |
| 211 | + if sup_el: | |
| 212 | + out["superficie"] = re.sub( | |
| 213 | + r"\s+", " ", sup_el.get_text(" ", strip=True)).strip() | |
| 214 | + | |
| 215 | + out["inclusions"] = [ | |
| 216 | + re.sub(r"\s+", " ", el.get_text(" ", strip=True)).strip() | |
| 217 | + for el in soup.select(".single_modele_inclus_item_name") | |
| 218 | + if el.get_text(strip=True)][:30] | |
| 219 | + | |
| 220 | + # galerie : photos du bloc .single_modele_galerie seulement — les | |
| 221 | + # blocs slider_modele_* en pied de page sont les AUTRES logements | |
| 222 | + images: list[str] = [] | |
| 223 | + gal = soup.select_one(".single_modele_galerie") | |
| 224 | + if gal: | |
| 225 | + for u in re.findall( | |
| 226 | + r"https://terater\.ca/wp-content/uploads/[^\"'()\s]+" | |
| 227 | + r"\.(?:jpg|jpeg|webp)", str(gal), re.I): | |
| 228 | + u = _SIZE_SUFFIX.sub("", u) | |
| 229 | + if u not in images: | |
| 230 | + images.append(u) | |
| 231 | + out["images"] = images[:30] | |
| 232 | + return out | |
| 233 | + | |
| 234 | + def _apply_detail(self, lst: Listing, d: dict) -> None: | |
| 235 | + if not d: | |
| 236 | + return | |
| 237 | + if d.get("superficie"): # « Superficie : 1375 pi² » | |
| 238 | + lst.amenities = list(dict.fromkeys(lst.amenities + [d["superficie"]])) | |
| 239 | + if d.get("inclusions"): | |
| 240 | + lst.amenities = list(dict.fromkeys(lst.amenities + d["inclusions"])) | |
| 241 | + if d.get("images"): | |
| 242 | + merged = list(dict.fromkeys(d["images"] + lst.images)) | |
| 243 | + lst.images = merged[:30] | |
added
reports/connectors/alexcellence.md
+32 −0
@@ -0,0 +1,32 @@ | ||
| 1 | +# alexcellence — Immeubles Alexcellence | |
| 2 | +- site: https://immeublesalexcellence.com/appartements-a-louer/ (WordPress + | |
| 3 | + thème Houzez, cartes item-listing-wrap v6) | |
| 4 | +- méthode: 4e membre de la famille Houzez de l'Estrie — sous-classe de | |
| 5 | + DynamicConnector (dynamic.py), ne redéfinit que les constantes (BASE, | |
| 6 | + LIST_PATH, CITY_DEFAULT) et le crochet _card_availability | |
| 7 | +- annonces: 6 à louer (sur ~33 cartes dont 13+ marquées LOUÉ et 1 entrepôt | |
| 8 | + exclu) — secteurs Université de Sherbrooke et Mont-Bellevue | |
| 9 | +- couverture: prix 100% (« 1,010$/par mois »), dispo 100% + date 100% | |
| 10 | + (étiquette « 1er août 2026 »), type 100%, description 100%, images 100% | |
| 11 | + (data-images), adresse 50% (address.item-address absent de certaines | |
| 12 | + fiches ; la rue figure alors dans le titre) | |
| 13 | +- fixture: ok (8 requêtes) · test: ok (2 verts) | |
| 14 | + | |
| 15 | +## Découverte | |
| 16 | +- robots.txt : permissif (`Disallow:` Yoast + wpforms/uploads). Vérifié. | |
| 17 | + Nota : le WAF renvoie parfois 403 sur des rafales — politesse 0,7 s. | |
| 18 | +- Particularité locale des étiquettes Houzez `label-status` : elles portent à | |
| 19 | + la fois le statut (« À LOUER » / « LOUÉ ») et la date de libération | |
| 20 | + (« 1er janvier 2027 ») — le crochet renvoie « LOUÉ » (carte écartée par le | |
| 21 | + parent) ou la date, jamais « À LOUER » (qui matcherait le filtre « loué » | |
| 22 | + du parent). | |
| 23 | +- external_id = data-listid Houzez (les slugs /appartements/<slug>/ ne | |
| 24 | + matchent pas la regex du parent — sans conséquence, le listid prime). | |
| 25 | +- Fiches détail Houzez réduites : description + address.item-address + | |
| 26 | + aperçu ; pas de bloc adresse structuré, pas de features, pas de GPS. | |
| 27 | + | |
| 28 | +## Fragilités | |
| 29 | +- Étiquettes de statut en texte libre (dates « 1 er octobre 2026 » avec | |
| 30 | + espace) — transmises brutes, parse_availability_date s'en charge. | |
| 31 | +- Incohérences éditoriales du site (titre « 4 ½ » sur un slug « 3-½ ») — | |
| 32 | + champs restitués tels quels, rien n'est réinterprété. | |
added
reports/connectors/immeubles_pm.md
+33 −0
@@ -0,0 +1,33 @@ | ||
| 1 | +# immeubles_pm — Les Immeubles PM | |
| 2 | +- site: https://www.lesimmeublespm.com (plateforme « plogg solutions », comme | |
| 3 | + appartementsgranby.com/godbout) — 6 immeubles à Sherbrooke (Fleurimont, | |
| 4 | + Sherbrooke Est) | |
| 5 | +- méthode: la page d'accueil rend côté serveur le bloc #listing-appart — une | |
| 6 | + carte .box par immeuble AYANT DES UNITÉS LIBRES, avec tableau Grandeur / | |
| 7 | + Loyer (« à partir de 1475$ ») / Disponible + lien « Consulter la fiche » ; | |
| 8 | + fiches /appartement/<slug>/ via self.detail (cache BD) | |
| 9 | +- annonces: 2 (4½ à 1475$ au 2035 André-Breton, 4½ à 1425$ au 2059 | |
| 10 | + André-Breton — libres immédiatement) | |
| 11 | +- couverture: prix 100%, dispo 100% + date 100%, type 100%, adresse 100% | |
| 12 | + (champ « Rue » de la fiche), ville/quartier 100%, superficie 100% (1510 et | |
| 13 | + 1260 pi²), étage et salles de bain (commodités), description 100% | |
| 14 | + (div.extrait avec liste d'inclusions), images 100% (façade) | |
| 15 | +- fixture: ok (3 requêtes) · test: ok (2 verts) | |
| 16 | + | |
| 17 | +## Découverte | |
| 18 | +- robots.txt : bloque Yandex/Naver/Baidu/Ahrefs uniquement ; `User-agent: *` | |
| 19 | + sans Disallow. Vérifié. | |
| 20 | +- Même plateforme plogg que godbout (Granby, resté non connectable) : ici la | |
| 21 | + recherche /rechercher-un-appartement/ affiche aussi « Complet » partout en | |
| 22 | + rendu statique (form-manager fermé), MAIS l'accueil expose le vrai carrousel | |
| 23 | + des unités libres — c'est lui qui fait foi. Le sitemap appartement (12 | |
| 24 | + fiches, lastmod 2026-01) contient aussi des unités-types non disponibles : | |
| 25 | + ne pas s'en servir pour la découverte. | |
| 26 | +- external_id = slug de fiche /appartement/<slug>/ (stable, une par | |
| 27 | + unité-type d'immeuble). | |
| 28 | +- Filtre de prudence : lignes « Complet »/« Loué » écartées. | |
| 29 | + | |
| 30 | +## Fragilités | |
| 31 | +- Petite plateforme régionale (plogg) au HTML tabulaire — sélecteurs | |
| 32 | + .box/.infos-appart maison. | |
| 33 | +- `<sup>2</sup>` des superficies recollé en « pi² » avant textmine. | |
added
reports/connectors/lequerre.md
+29 −0
@@ -0,0 +1,29 @@ | ||
| 1 | +# lequerre — Habitations L'Équerre | |
| 2 | +- site: https://habitationslequerre.com/immeubles/ (WordPress, gabarit maison | |
| 3 | + Tailwind rendu serveur) — OBNL d'habitation de Sherbrooke, 34 immeubles | |
| 4 | + abordables (locatif privé consultable, pas du HLM) | |
| 5 | +- méthode: une <section class="building-section"> par immeuble vedette ; | |
| 6 | + seules les sections data-category « disponibles » (onglet « Disponibles / | |
| 7 | + À louer » du site) deviennent des annonces — badge « DISPONIBLE MAINTENANT | |
| 8 | + - 735$ / mois », h2 (type), h3 (adresse), paragraphe descriptif, visuel | |
| 9 | +- annonces: 1 (Studios meublés et tout inclus — 14, rue Jean-Maurice, | |
| 10 | + Sherbrooke, 735 $/mois, disponible maintenant ; 8 autres immeubles affichés | |
| 11 | + sans unité libre) | |
| 12 | +- couverture: prix 100% (badge), dispo 100% + date 100% (« DISPONIBLE | |
| 13 | + MAINTENANT »), type 100% (Studio), adresse 100%, ville 100%, description | |
| 14 | + 100%, image 100% ; granularité immeuble (le site ne publie pas d'unités | |
| 15 | + individuelles datées) | |
| 16 | +- fixture: ok (1 requête) · test: ok (2 verts) | |
| 17 | + | |
| 18 | +## Découverte | |
| 19 | +- robots.txt : WP standard (/wp-admin/ seul). Vérifié. | |
| 20 | +- La page /immeubles/ est un gabarit codé main (classes Tailwind + filtres | |
| 21 | + JS par data-category) — pas un CPT WordPress : le badge de dispo est le | |
| 22 | + seul marqueur d'inventaire ; « DISPONIBLE MAINTENANT - 735$ / mois » est | |
| 23 | + scindé en disponibilité + étiquette de prix. | |
| 24 | +- external_id = adresse civique normalisée en slug (stable). | |
| 25 | +- URL = page /immeubles/ (pas de page par immeuble). | |
| 26 | + | |
| 27 | +## Fragilités | |
| 28 | +- Gabarit maison susceptible d'être refondu (page générée en mars 2026) ; | |
| 29 | + si le badge disparaît, la source retombe à zéro annonce (échec sûr). | |
added
reports/connectors/majestic.md
+35 −0
@@ -0,0 +1,35 @@ | ||
| 1 | +# majestic — Le Majestic du Lac Boivin | |
| 2 | +- site: https://www.majesticlacboivin.ca/fr/condos-locatifs.html (site custom | |
| 3 | + non-WordPress) — 1 complexe de condos locatifs haut de gamme à Granby, | |
| 4 | + 150 rue de la Lobélie (adresse affichée sur /fr/nous-joindre.html, | |
| 5 | + schema.org PostalAddress) | |
| 6 | +- méthode: la page des condos rend côté serveur la liste des unités libres | |
| 7 | + (div.uniteItem[data-id] dans .listeResultatUnite) : unité, étage, chambres, | |
| 8 | + salles de bain, superficie, loyer, disponibilité, plan PDF ; les unités | |
| 9 | + « Réservé » (span.nonDispo) sont écartées | |
| 10 | +- annonces: 1 (Unité 308 — 1 ch., 735 pi², 1695 $, 1er juillet ; deux autres | |
| 11 | + unités affichées mais réservées) | |
| 12 | +- couverture: prix 100% (colonne « Loyer » — montant extrait même sans « $ », | |
| 13 | + formats du site : « 1695 $ », « 1795 », « $3500 »), dispo 100% + date 100% | |
| 14 | + (« 1er juillet », « Immédiate »), superficie 100% (area_sqft), étage/ | |
| 15 | + chambres/sdb (commodités), inclusions et avantages de l'immeuble | |
| 16 | + (.services-liste) ; pas de type ½ publié (chambres seulement), pas de | |
| 17 | + galerie par unité (plans PDF conservés dans details.plan_pdf) | |
| 18 | +- fixture: ok (1 requête) · test: ok (2 verts) | |
| 19 | + | |
| 20 | +## Découverte | |
| 21 | +- robots.txt : Disallow /inc/, /Gestion/, /auto/ — la page des condos est | |
| 22 | + permise ; crawl-delay 15 pour bingbot seulement. Vérifié. | |
| 23 | +- Le sélecteur d'étages interactif (imagemapster) et « Charger plus de | |
| 24 | + résultats » sont purement client : toutes les unités libres sont déjà dans | |
| 25 | + le HTML initial (nombreMax=86 = parc total, 3 unités rendues). | |
| 26 | +- external_id = data-id interne de l'unité (69 -> Unité 308) ; URL = page des | |
| 27 | + condos (pas de page par unité — le bouton « Info » pointe le formulaire de | |
| 28 | + contact ?uniteId=…). | |
| 29 | + | |
| 30 | +## Fragilités | |
| 31 | +- Formats de loyer irréguliers (avec/sans $) — extraction dédiée à la colonne | |
| 32 | + « Loyer », bornée 100–20 000 $. | |
| 33 | +- Adresse du complexe en constante (source : page Nous joindre) — immeuble | |
| 34 | + unique, ne bouge pas ; à revalider seulement si le site refond sa page | |
| 35 | + contact. | |
added
reports/connectors/oxford.md
+32 −0
@@ -0,0 +1,32 @@ | ||
| 1 | +# oxford — Appartements Oxford | |
| 2 | +- site: https://appartoxford.com (WordPress + Elementor, WPML) — Lennoxville | |
| 3 | + (Sherbrooke), près de l'Université Bishop's | |
| 4 | +- méthode: fiches par TYPE d'unité (2½ tout inclus, 3½, 4½, 5½) listées dans | |
| 5 | + le menu « Logements » de l'accueil ; une fiche n'est une annonce que si son | |
| 6 | + encart de disponibilité (widget Elementor à visibilité conditionnelle) est | |
| 7 | + présent — « 1 unité disponible 1er octobre 2026 » | |
| 8 | +- annonces: 2 (3½ dès 760$ — 15 septembre 2026 ; 4½ dès 1000$ — 1er octobre | |
| 9 | + 2026 ; les fiches 2½ et 5½ n'affichent aucune unité libre et sont écartées) | |
| 10 | +- couverture: prix 100% (« À partir de 760$ / par mois », price_from), | |
| 11 | + dispo 100% + date 100%, type 100%, description 100%, caractéristiques 100% | |
| 12 | + (listes à pictos, sans le bruit d'en-tête), images 100% ; adresse civique | |
| 13 | + non publiée (secteur Lennoxville, ville Sherbrooke — affichés sur le site) | |
| 14 | +- fixture: ok (11 requêtes) · test: ok (2 verts) | |
| 15 | + | |
| 16 | +## Découverte | |
| 17 | +- robots.txt : WP standard (/wp-admin/ seul). Vérifié. | |
| 18 | +- Le sitemap wp-sitemap est vide d'index utile — la découverte passe par les | |
| 19 | + liens /appartement/<slug>/ de l'accueil ; les anciens slugs (« 4½-a-louer ») | |
| 20 | + redirigent 301 vers les fiches canoniques : external_id = slug FINAL décodé | |
| 21 | + et minuscule (dédoublonne les variantes d'encodage %C2%BD/%c2%bd). | |
| 22 | +- Peu de requêtes (accueil + ~7 fiches dont doublons redirigés) : pas de | |
| 23 | + cache détail — l'encart de dispo doit être relu à chaque synchronisation. | |
| 24 | +- « Dernière modification : juin 2026 » affichée sur les fiches : le contenu | |
| 25 | + est maintenu. | |
| 26 | + | |
| 27 | +## Fragilités | |
| 28 | +- Annonce à la granularité type d'unité (« 1 unité disponible ») — pas de | |
| 29 | + numéro d'appartement ni d'adresse civique publiés. | |
| 30 | +- L'encart de dispo repose sur un widget dce-visibility : si le site le | |
| 31 | + reformule (autre libellé que « N unité(s) disponible(s) »), les annonces | |
| 32 | + disparaissent (échec sûr, rien d'inventé). | |
added
reports/connectors/terater.md
+39 −0
@@ -0,0 +1,39 @@ | ||
| 1 | +# terater — Les Constructions Tèratèr | |
| 2 | +- site: https://terater.ca/a-louer/ (WordPress + thème enfant Avada, blocs maison) | |
| 3 | +- méthode: archive /a-louer/ rendue serveur — une carte | |
| 4 | + div.landing_modele_block_item par logement ; fiches via self.detail | |
| 5 | + (cache BD) pour superficie, inclusions et galerie | |
| 6 | +- annonces: 12 (Val-Joli ×5, Sherbrooke Nord/Seyval ×4, Orford ×2, | |
| 7 | + Cookshire-Eaton ×1 — le constructeur couvre aussi Windsor via Val-Joli) | |
| 8 | +- couverture: prix 100% (« 1 075$ par mois »), dispo 100% (« Libre | |
| 9 | + immédiatement », « Juillet 2026 »… bandeau coloré), date 100%, type 100% | |
| 10 | + (badge « 3 ½ »), adresse 100% (carte), superficie 100% (fiche), images 100% | |
| 11 | + (galerie de la fiche), description 0% (le site n'en publie pas — les | |
| 12 | + inclusions détaillées tiennent lieu de caractéristiques) | |
| 13 | +- fixture: ok (13 requêtes) · test: ok (2 verts) | |
| 14 | + | |
| 15 | +## Découverte | |
| 16 | +- robots.txt : Yoast `Disallow:` (tout permis). Vérifié. | |
| 17 | +- Même famille de balisage que morin.py (Avada child + classes maison), mais | |
| 18 | + préfixes différents : `landing_modele_*` (archive), `single_modele_*` | |
| 19 | + (fiche), `slider_modele_*` (carrousel « autres logements » — ignoré pour ne | |
| 20 | + pas mélanger les galeries). | |
| 21 | +- external_id = slug d'URL /a-louer/<slug>/ (stable ; les slugs « -copy » du | |
| 22 | + site sont conservés tels quels). | |
| 23 | +- Aucune carte « Loué » dans l'archive au moment de l'écriture (les loués en | |
| 24 | + sortent) ; filtre par prudence sur le bandeau et exclusion des espaces non | |
| 25 | + résidentiels via le champ type. | |
| 26 | + | |
| 27 | +## Champs extraits | |
| 28 | +- carte : bandeau dispo, badge type, adresse (« 550, rte 143 Sud, Val-Joli | |
| 29 | + #3 »), secteur/ville (mappé sur villes réelles connues : Val-Joli, | |
| 30 | + Cookshire-Eaton, Orford ; « Nord de Sherbrooke » -> Sherbrooke + secteur), | |
| 31 | + chambres / salle de bain / étage / stationnement / « Animaux accepter » | |
| 32 | + (texte du site), prix + « par mois », visuel. | |
| 33 | +- fiche : « Superficie : 1375 pi² » (commodité, area_sqft dérivé), inclusions | |
| 34 | + (single_modele_inclus_item_name), galerie .single_modele_galerie (variantes | |
| 35 | + redimensionnées ramenées à la pleine taille). | |
| 36 | + | |
| 37 | +## Fragilités | |
| 38 | +- Classes maison du thème enfant Avada (landing_modele_*/single_modele_*). | |
| 39 | +- HTML du site avec coquilles (`<soan>`) — parsing tolérant par classes. | |
added
reports/sources-entries/alexcellence.json
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +{ | |
| 2 | + "id": "alexcellence", | |
| 3 | + "name": "Immeubles Alexcellence", | |
| 4 | + "url": "https://immeublesalexcellence.com", | |
| 5 | + "listing_url": "https://immeublesalexcellence.com/appartements-a-louer/", | |
| 6 | + "sectors": "Sherbrooke (Université de Sherbrooke, Mont-Bellevue)", | |
| 7 | + "connector": "alexcellence", | |
| 8 | + "status": "actif", | |
| 9 | + "region": "Estrie" | |
| 10 | +} | |
added
reports/sources-entries/bloc.json
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +{ | |
| 2 | + "id": "bloc", | |
| 3 | + "name": "Société Immobilière BLOC", | |
| 4 | + "url": "https://www.immobilierbloc.ca", | |
| 5 | + "listing_url": "https://www.immobilierbloc.ca/immeubles", | |
| 6 | + "sectors": "Magog et environs (Ovila-Bergeron, Rexford…)", | |
| 7 | + "connector": null, | |
| 8 | + "status": "non connectable — site Wix : la page /logements-a-louer référencée au menu répond 404 (page retirée) et les 4 pages d'immeubles restantes sont descriptives, sans unité, prix ni disponibilité ; re-vérifier si la page des logements revient", | |
| 9 | + "region": "Estrie" | |
| 10 | +} | |
added
reports/sources-entries/devco.json
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +{ | |
| 2 | + "id": "devco", | |
| 3 | + "name": "Groupe Devco Immobilier", | |
| 4 | + "url": "https://www.groupedevcoimmobilier.com", | |
| 5 | + "listing_url": "https://www.groupedevcoimmobilier.com/realisation", | |
| 6 | + "sectors": "Coaticook, Sherbrooke, Compton, Cookshire-Eaton, Magog", | |
| 7 | + "connector": null, | |
| 8 | + "status": "non connectable — site Wix sans module d'annonces : l'unique offre courante (« À louer - 593 Ave des Érables… 1 500$/mois ») est un paragraphe de texte riche inséré à la main dans la page vitrine /realisation, sans identifiant, structure ni page dédiée — trop fragile et non suivable de façon fiable ; re-vérifier si un vrai répéteur d'annonces apparaît", | |
| 9 | + "region": "Estrie" | |
| 10 | +} | |
added
reports/sources-entries/gci.json
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +{ | |
| 2 | + "id": "gci", | |
| 3 | + "name": "Les Immeubles GCI", | |
| 4 | + "url": "https://lesimmeublesgci.com", | |
| 5 | + "listing_url": "https://lesimmeublesgci.com/#nos-immeubles", | |
| 6 | + "sectors": "Sherbrooke (Domaine Duvernay)", | |
| 7 | + "connector": null, | |
| 8 | + "status": "non connectable — one-page WP 5.3/WPBakery : 12 cartes d'immeubles avec badges « À louer »/« Loué » mais en fourchettes de bâtiment (« 1200 à 1400 pi², 2 à 3 chambres »), sans prix, sans date, et liens des cartes morts (href=#) ; pas d'unités réelles exploitables", | |
| 9 | + "region": "Estrie" | |
| 10 | +} | |
added
reports/sources-entries/gocar.json
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +{ | |
| 2 | + "id": "gocar", | |
| 3 | + "name": "Gestion GOCAR", | |
| 4 | + "url": "https://gestiongocar.com", | |
| 5 | + "listing_url": "https://gestiongocar.com/", | |
| 6 | + "sectors": "Cowansville (boul. J.-J.-Bertrand, rue Albert…)", | |
| 7 | + "connector": null, | |
| 8 | + "status": "non connectable actuellement — l'accueil liste bien les immeubles mais chacun est marqué « Complet — Informez-vous pour les prochaines disponibilités » (aucun logement, prix ni date publiés ; consigne d'appeler le 450 522-4907) ; re-vérifier quand des unités se libèrent", | |
| 9 | + "region": "Montérégie" | |
| 10 | +} | |
added
reports/sources-entries/godin.json
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +{ | |
| 2 | + "id": "godin", | |
| 3 | + "name": "Gestion A. Godin", | |
| 4 | + "url": "https://gestionagodin.com", | |
| 5 | + "listing_url": "https://gestionagodin.com/property-type/logement-a-louer/", | |
| 6 | + "sectors": "Montérégie (annonces actuelles : Longueuil, Greenfield Park — pas de Granby)", | |
| 7 | + "connector": null, | |
| 8 | + "status": "non connectable — thème RealHomes avec seulement 3 fiches vitrines « logement à louer » (78 rue Briand, 90 de l'Église…), toutes hors zone Granby (Longueuil/Greenfield Park) et sans prix ni disponibilité, ni sur l'archive ni sur les fiches ; portefeuille surtout commercial", | |
| 9 | + "region": "Montérégie" | |
| 10 | +} | |
added
reports/sources-entries/immeubles_pm.json
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +{ | |
| 2 | + "id": "immeubles_pm", | |
| 3 | + "name": "Les Immeubles PM", | |
| 4 | + "url": "https://www.lesimmeublespm.com", | |
| 5 | + "listing_url": "https://www.lesimmeublespm.com/", | |
| 6 | + "sectors": "Sherbrooke (Fleurimont – Quatre Saisons et King Est, Sherbrooke Est – Bowen Sud)", | |
| 7 | + "connector": "immeubles_pm", | |
| 8 | + "status": "actif", | |
| 9 | + "region": "Estrie" | |
| 10 | +} | |
added
reports/sources-entries/immoestrie.json
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +{ | |
| 2 | + "id": "immoestrie", | |
| 3 | + "name": "Gestion immobilière de l'Estrie", | |
| 4 | + "url": "https://immoestrie.ca", | |
| 5 | + "listing_url": "https://immoestrie.ca/nos-immeubles/", | |
| 6 | + "sectors": "Sherbrooke/Estrie", | |
| 7 | + "connector": null, | |
| 8 | + "status": "non connectable — le site s'est réorienté en offre de services de gestion POUR propriétaires (perception des loyers, baux, Tribunal administratif du logement…) : /nos-immeubles/ est une page de services avec formulaire de soumission, aucune annonce de logement", | |
| 9 | + "region": "Estrie" | |
| 10 | +} | |
added
reports/sources-entries/innoplex.json
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +{ | |
| 2 | + "id": "innoplex", | |
| 3 | + "name": "Innoplex Immobilier", | |
| 4 | + "url": "https://innopleximmobilier.com", | |
| 5 | + "listing_url": "https://innopleximmobilier.com/fr/nos-immeubles/", | |
| 6 | + "sectors": "Sherbrooke et Estrie (6+ immeubles)", | |
| 7 | + "connector": null, | |
| 8 | + "status": "non connectable — « Nos immeubles » (4 pages paginées) et les fiches d'immeuble sont des présentations de bâtiments : aucune unité, aucun prix, aucune disponibilité sur l'archive ni sur les fiches sondées", | |
| 9 | + "region": "Estrie" | |
| 10 | +} | |
added
reports/sources-entries/jpg.json
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +{ | |
| 2 | + "id": "jpg", | |
| 3 | + "name": "Construction JPG", | |
| 4 | + "url": "https://www.constructionjpg.com", | |
| 5 | + "listing_url": "https://www.constructionjpg.com/immeubles-liste.cfm", | |
| 6 | + "sectors": "Sherbrooke (Nord, Est, Rock Forest, milieu urbain), East Angus", | |
| 7 | + "connector": null, | |
| 8 | + "status": "non connectable — site statique ColdFusion : pages brochurables par secteur (Boisé Émile-Zola, Boisé Urbain…) avec visites virtuelles, mais aucune unité, aucun prix, aucune disponibilité ; location au téléphone (819 578-1490)", | |
| 9 | + "region": "Estrie" | |
| 10 | +} | |
added
reports/sources-entries/landry.json
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +{ | |
| 2 | + "id": "landry", | |
| 3 | + "name": "Immeubles Georges Landry", | |
| 4 | + "url": "https://immeublesgeorgeslandry.com", | |
| 5 | + "listing_url": "https://immeublesgeorgeslandry.com/location-de-logements/", | |
| 6 | + "sectors": "Granby (logements 50 ans et plus)", | |
| 7 | + "connector": null, | |
| 8 | + "status": "non connectable — site vitrine Divi : la page « Location de logements » est un texte de présentation (50+) avec galerie photos ; aucune unité listée, aucun prix, aucune disponibilité, pas de CPT immobilier", | |
| 9 | + "region": "Montérégie" | |
| 10 | +} | |
added
reports/sources-entries/lequerre.json
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +{ | |
| 2 | + "id": "lequerre", | |
| 3 | + "name": "Habitations L'Équerre", | |
| 4 | + "url": "https://habitationslequerre.com", | |
| 5 | + "listing_url": "https://habitationslequerre.com/immeubles/", | |
| 6 | + "sectors": "Sherbrooke (centre-ville et quartiers — 34 immeubles abordables, OBNL)", | |
| 7 | + "connector": "lequerre", | |
| 8 | + "status": "actif", | |
| 9 | + "region": "Estrie" | |
| 10 | +} | |
added
reports/sources-entries/lpl.json
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +{ | |
| 2 | + "id": "lpl", | |
| 3 | + "name": "Immeubles L.P.L. (Loyer Sherbrooke)", | |
| 4 | + "url": "https://loyersherbrooke.com", | |
| 5 | + "listing_url": "https://loyersherbrooke.com/estrie/sherbrooke/", | |
| 6 | + "sectors": "Sherbrooke (Fleurimont, Jacques-Cartier, Rock Forest…)", | |
| 7 | + "connector": null, | |
| 8 | + "status": "non connectable — arborescence SEO par secteur/grandeur et fiches property/ d'immeubles (Le 459, Jardins du Parc…) purement descriptives ; « Rechercher un appartement » n'est qu'un filtre par grandeur sans résultats datés : aucun prix ni disponibilité nulle part", | |
| 9 | + "region": "Estrie" | |
| 10 | +} | |
added
reports/sources-entries/majestic.json
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +{ | |
| 2 | + "id": "majestic", | |
| 3 | + "name": "Le Majestic du Lac Boivin", | |
| 4 | + "url": "https://www.majesticlacboivin.ca", | |
| 5 | + "listing_url": "https://www.majesticlacboivin.ca/fr/condos-locatifs.html", | |
| 6 | + "sectors": "Granby (150 rue de la Lobélie, lac Boivin)", | |
| 7 | + "connector": "majestic", | |
| 8 | + "status": "actif", | |
| 9 | + "region": "Montérégie" | |
| 10 | +} | |
added
reports/sources-entries/marc_breton.json
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +{ | |
| 2 | + "id": "marc_breton", | |
| 3 | + "name": "Gestion Marc Breton", | |
| 4 | + "url": "https://gestionmarcbreton.com", | |
| 5 | + "listing_url": "https://gestionmarcbreton.com/immeubles-locatifs", | |
| 6 | + "sectors": "Granby, Bromont", | |
| 7 | + "connector": null, | |
| 8 | + "status": "non connectable — pages « Immeubles locatifs » / « Locatif Granby » / « Locatif Bromont » rédactionnelles (description des quartiers et des jumelés haut de gamme) : aucune annonce, aucun prix, aucune disponibilité datée", | |
| 9 | + "region": "Montérégie" | |
| 10 | +} | |
added
reports/sources-entries/memphremagog.json
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +{ | |
| 2 | + "id": "memphremagog", | |
| 3 | + "name": "Groupe Immobilier Memphrémagog", | |
| 4 | + "url": "https://groupeimmobiliermemphremagog.com", | |
| 5 | + "listing_url": "https://groupeimmobiliermemphremagog.com/#location", | |
| 6 | + "sectors": "Magog (Côteaux du Marais), Sherbrooke", | |
| 7 | + "connector": null, | |
| 8 | + "status": "non connectable — vitrine one-page de promoteur (prestige 55+) : blocs « VOIR LE PROJET » et texte « Pourquoi louer ? », aucune annonce, aucun prix, aucune disponibilité publiée", | |
| 9 | + "region": "Estrie" | |
| 10 | +} | |
added
reports/sources-entries/novard.json
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +{ | |
| 2 | + "id": "novard", | |
| 3 | + "name": "Constructions Novard", | |
| 4 | + "url": "https://www.constructionsnovard.com", | |
| 5 | + "listing_url": "https://www.constructionsnovard.com/locatif", | |
| 6 | + "sectors": "Farnham, Bromont", | |
| 7 | + "connector": null, | |
| 8 | + "status": "non connectable — page « Immeubles résidentiels locatifs » (CMS Made Simple, © 2012) : un paragraphe de présentation et 3 photos ; aucune unité, aucun prix, aucune disponibilité, contact pour « planifier une visite »", | |
| 9 | + "region": "Montérégie" | |
| 10 | +} | |
added
reports/sources-entries/oxford.json
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +{ | |
| 2 | + "id": "oxford", | |
| 3 | + "name": "Appartements Oxford", | |
| 4 | + "url": "https://appartoxford.com", | |
| 5 | + "listing_url": "https://appartoxford.com", | |
| 6 | + "sectors": "Sherbrooke (Lennoxville, près de l'Université Bishop's)", | |
| 7 | + "connector": "oxford", | |
| 8 | + "status": "actif", | |
| 9 | + "region": "Estrie" | |
| 10 | +} | |
added
reports/sources-entries/ralph.json
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +{ | |
| 2 | + "id": "ralph", | |
| 3 | + "name": "Ralph condos locatifs", | |
| 4 | + "url": "https://ralphcondos.com", | |
| 5 | + "listing_url": "https://ralphcondos.com/condos/", | |
| 6 | + "sectors": "Magog (centre-ville, 1 immeuble mixte)", | |
| 7 | + "connector": null, | |
| 8 | + "status": "non connectable — vitrine WordPress d'un immeuble unique : pages /condos/ et /plans/ purement descriptives, aucune unité, aucun prix, aucune disponibilité (les seules mentions « disponible » du HTML sont des chaînes i18n de l'éditeur WP)", | |
| 9 | + "region": "Estrie" | |
| 10 | +} | |
added
reports/sources-entries/rivard.json
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +{ | |
| 2 | + "id": "rivard", | |
| 3 | + "name": "Les Habitations Rivard", | |
| 4 | + "url": "https://www.habitationsrivard.com", | |
| 5 | + "listing_url": "https://www.habitationsrivard.com/location", | |
| 6 | + "sectors": "Granby, Bromont, Shefford, Waterloo, Ange-Gardien, Farnham", | |
| 7 | + "connector": null, | |
| 8 | + "status": "non connectable — site Duda dont le « catalogue » de propriétés est une boutique JS (MAIN_STORE) chargée côté client, avec prix explicitement masqués (data-product-list-price-behavior=hide) ; aucune annonce, prix ni disponibilité en SSR, la page /location n'offre qu'un formulaire de demande d'information", | |
| 9 | + "region": "Montérégie" | |
| 10 | +} | |
added
reports/sources-entries/symphonie.json
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +{ | |
| 2 | + "id": "symphonie", | |
| 3 | + "name": "Symphonie de Cowansville", | |
| 4 | + "url": "https://symphoniedecowansville.ca", | |
| 5 | + "listing_url": "https://symphoniedecowansville.ca/les-unites/", | |
| 6 | + "sectors": "Cowansville (1 immeuble neuf 2022)", | |
| 7 | + "connector": null, | |
| 8 | + "status": "non connectable — site vitrine marketing : pages « Les unités » et « Plans | Prix » purement descriptives (visite guidée, plans en images), aucun prix, aucune unité datée, aucun module de disponibilité", | |
| 9 | + "region": "Montérégie" | |
| 10 | +} | |
added
reports/sources-entries/terater.json
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +{ | |
| 2 | + "id": "terater", | |
| 3 | + "name": "Les Constructions Tèratèr", | |
| 4 | + "url": "https://terater.ca", | |
| 5 | + "listing_url": "https://terater.ca/a-louer/", | |
| 6 | + "sectors": "Val-Joli/Windsor, Sherbrooke (Nord, Seyval, Fleurimont), Orford, Cookshire-Eaton", | |
| 7 | + "connector": "terater", | |
| 8 | + "status": "actif", | |
| 9 | + "region": "Estrie" | |
| 10 | +} | |
added
reports/sources-entries/verseau.json
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +{ | |
| 2 | + "id": "verseau", | |
| 3 | + "name": "Gestion Immobilière du Verseau", | |
| 4 | + "url": "https://gestionduverseau.com", | |
| 5 | + "listing_url": "https://gestionduverseau.com/appartements/", | |
| 6 | + "sectors": "Sherbrooke (6 secteurs)", | |
| 7 | + "connector": null, | |
| 8 | + "status": "non connectable — archive Opal Estate (WP 5.5) de 167 fiches figées : prix uniquement en fiche détail et périmés (« 3 1/2 … 550.00$/mois », « LIBRE AU 1ER JUILLET 2021 »), aucune date de disponibilité maintenue, étiquette « trop tard déjà loué » posée sur une poignée de fiches seulement — inventaire non entretenu depuis ~2021", | |
| 9 | + "region": "Estrie" | |
| 10 | +} | |
added
tests/fixtures/alexcellence/01c6f74c604126fcfb82.html
+2370 −0
Large diff (2,370 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/alexcellence/7cbd2387a6c04a56cfa8.html
+3155 −0
Large diff (3,155 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/alexcellence/85d22dc2783a3abf6220.html
+2354 −0
Large diff (2,354 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/alexcellence/a9ce416aa4dd1ba18b9d.html
+2389 −0
Large diff (2,389 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/alexcellence/b51aa380c29b19f6bb1e.html
+2108 −0
Large diff (2,108 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/alexcellence/c5f68f5fa1a3bd1da1bb.html
+2447 −0
Large diff (2,447 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/alexcellence/cbd0ebff4704f8c48e5b.html
+2362 −0
Large diff (2,362 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/alexcellence/ccac9b427dca248835f6.html
+1478 −0
Large diff (1,478 lines) is not loaded by default to keep the page fast.Load diff
Diff truncated — file too large.