SPB Git

spb/lou-ka

Public

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

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>
Simon-Pierre Boucher committed (Aug 9, 2026) parent b53b6e8 Browse files

43 changed files +20,001 −0

43 changed files
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.

Menu

Theme