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