Expansion P3 — Est & Nord + Chaudière-Appalaches (7 connecteurs +98 annonces, 9 non-connectables)
quartier_plus (Thetford, 59) · immeubles_bois (Rimouski, catalogue 23) · louer_saguenay · luxuor · beaulieu_groupe · gestion_jdm · alias_appartements. LM Gestion et Alexandre Pelletier re-vérifiés (sites toujours morts). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
41 changed files +20,001 −0
added
louka/connectors/alias_appartements.py
+101 −0
@@ -0,0 +1,101 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/alias_appartements.py : connecteur Alias Appartements | |
| 5 | +# (aliasappartements.com — rue des Mouettes, Jonquière, ville de Saguenay ; | |
| 6 | +# Groupe Boudreault). Projet locatif neuf UNIQUE de 80 unités 4½ livré en | |
| 7 | +# 4 phases (2023-2025). WordPress + Elementor + JetElements rendu serveur, | |
| 8 | +# page projet unique SANS fiches d'unités : le site publie le nombre | |
| 9 | +# d'unités (« 80 unités »), le type (« 4 1/2 »), le loyer plancher | |
| 10 | +# (« À partir de 1575$ »), l'adresse (h3 « Rue des Mouettes, Jonquière »), | |
| 11 | +# les caractéristiques (carrousel JetElements) et des photos — disponibilités | |
| 12 | +# par formulaire/contact. -> 1 annonce « catalogue » à granularité PROJET | |
| 13 | +# (précédent girs/immeubles_guillot), availability vide, rien d'inventé ; | |
| 14 | +# price via price_label (« à partir de », plus bas loyer). | |
| 15 | +# external_id stable : rue-des-mouettes. | |
| 16 | +# robots.txt Yoast ouvert (Disallow vide), sitemap public. | |
| 17 | +# ----------------------------------------------------------------------------- | |
| 18 | +from __future__ import annotations | |
| 19 | + | |
| 20 | +import re | |
| 21 | + | |
| 22 | +from bs4 import BeautifulSoup | |
| 23 | + | |
| 24 | +from ..schema import Listing, normalize_unit_type | |
| 25 | +from .base import BaseConnector | |
| 26 | + | |
| 27 | +BASE = "https://www.aliasappartements.com" | |
| 28 | + | |
| 29 | +_UNITS_RE = re.compile(r"^\d+\s+unités$", re.I) | |
| 30 | +_TYPE_RE = re.compile(r"^([1-6])\s*(?:½|1/2)\s*$") | |
| 31 | +_PRICE_RE = re.compile(r"^à\s+partir\s+de\s+[\d\s]+\$", re.I) | |
| 32 | +_SKIP_IMG = re.compile(r"\.svg$|logo|icon|Group-\d+", re.I) | |
| 33 | + | |
| 34 | + | |
| 35 | +class AliasAppartementsConnector(BaseConnector): | |
| 36 | + source_id = "alias_appartements" | |
| 37 | + request_delay = 0.6 | |
| 38 | + max_images = 10 | |
| 39 | + | |
| 40 | + def fetch(self) -> list[Listing]: | |
| 41 | + soup = BeautifulSoup(self.get(BASE + "/").text, "html.parser") | |
| 42 | + | |
| 43 | + units_txt, unit_type, price_label, address = "", "", "", "" | |
| 44 | + for h in soup.select(".elementor-heading-title"): | |
| 45 | + t = re.sub(r"\s+", " ", h.get_text(" ", strip=True)) | |
| 46 | + if not t: | |
| 47 | + continue | |
| 48 | + if _UNITS_RE.match(t): | |
| 49 | + units_txt = t | |
| 50 | + elif _TYPE_RE.match(t): | |
| 51 | + unit_type = normalize_unit_type(t) | |
| 52 | + elif _PRICE_RE.match(t) and not price_label: | |
| 53 | + price_label = t | |
| 54 | + elif h.name == "h3" and re.search(r"(?i)mouettes", t): | |
| 55 | + address = t | |
| 56 | + | |
| 57 | + if not address and not units_txt: | |
| 58 | + return [] # page méconnaissable : ne rien émettre | |
| 59 | + | |
| 60 | + # caractéristiques : carrousel JetElements | |
| 61 | + amenities: list[str] = [] | |
| 62 | + if units_txt: | |
| 63 | + amenities.append(units_txt) | |
| 64 | + for h5 in soup.select("h5.jet-carousel__item-title"): | |
| 65 | + t = re.sub(r"\s+", " ", h5.get_text(" ", strip=True)) | |
| 66 | + if t and t not in amenities: | |
| 67 | + amenities.append(t) | |
| 68 | + | |
| 69 | + # paragraphes descriptifs (hors pied de page) | |
| 70 | + paras: list[str] = [] | |
| 71 | + for p in soup.select(".elementor-widget-text-editor p"): | |
| 72 | + t = re.sub(r"\s+", " ", p.get_text(" ", strip=True)) | |
| 73 | + if len(t) >= 100 and "©" not in t and len(paras) < 5: | |
| 74 | + paras.append(t) | |
| 75 | + description = "\n".join(paras)[:1600] | |
| 76 | + | |
| 77 | + images: list[str] = [] | |
| 78 | + for img in soup.select("img[src*='/wp-content/uploads/']"): | |
| 79 | + u = img["src"] | |
| 80 | + if (u.startswith("http") and not _SKIP_IMG.search(u) | |
| 81 | + and u not in images): | |
| 82 | + images.append(u) | |
| 83 | + if len(images) >= self.max_images: | |
| 84 | + break | |
| 85 | + | |
| 86 | + return [Listing( | |
| 87 | + source=self.source_id, | |
| 88 | + external_id="rue-des-mouettes", | |
| 89 | + url=BASE + "/", | |
| 90 | + title=f"Alias Appartements — {address or 'Rue des Mouettes, Jonquière'}", | |
| 91 | + address=address or "Rue des Mouettes, Jonquière", | |
| 92 | + sector="Jonquière", | |
| 93 | + city="Saguenay", | |
| 94 | + unit_type=unit_type, | |
| 95 | + price=None, # déduit de price_label (« à partir de ») | |
| 96 | + price_label=price_label, | |
| 97 | + availability="", # disponibilités par formulaire/contact | |
| 98 | + description=description, | |
| 99 | + amenities=amenities[:30], | |
| 100 | + images=images, | |
| 101 | + )] | |
added
louka/connectors/beaulieu_groupe.py
+136 −0
@@ -0,0 +1,136 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/beaulieu_groupe.py : connecteur Beaulieu Groupe Immobilier / BGI | |
| 5 | +# (beaulieugroupeimmobilier.com — Rimouski, Bas-Saint-Laurent ; distinct des | |
| 6 | +# Immeubles Beaulieu et Collin `immeubles_bc` et de Beaulieu Gestion à | |
| 7 | +# Rivière-du-Loup). WordPress + Elementor avec un shortcode maison : | |
| 8 | +# - archive /batiment/?category=residentiel : cartes `div.card-batiment` | |
| 9 | +# par immeuble avec statut « COMPLET » ou « Locaux disponibles : N » ; | |
| 10 | +# - fiche /batiment/<slug>/ : description de l'immeuble + cartes d'unités | |
| 11 | +# `div.card-local` (« 418 Alcide-C.-Horth, app #206 | 4 1/2 ») dont le | |
| 12 | +# `p.card-text` vaut « Indisponible » (image générique Complet.png) ou, | |
| 13 | +# pour les unités libres, « Superficie : N pi² » avec photo réelle. | |
| 14 | +# Le connecteur ne visite que les fiches des immeubles NON marqués COMPLET | |
| 15 | +# (politesse) et n'émet que les unités non « Indisponible ». Aucun prix | |
| 16 | +# publié -> price=None, rien d'inventé. Tout le parc affiché est à Rimouski. | |
| 17 | +# external_id stable : slug de la fiche + numéro d'appartement | |
| 18 | +# (418-rue-alcide-c-horth--app-206). | |
| 19 | +# robots.txt Yoast ouvert (Disallow vide), sitemap public. | |
| 20 | +# ----------------------------------------------------------------------------- | |
| 21 | +from __future__ import annotations | |
| 22 | + | |
| 23 | +import re | |
| 24 | + | |
| 25 | +from bs4 import BeautifulSoup | |
| 26 | + | |
| 27 | +from ..schema import Listing, normalize_unit_type | |
| 28 | +from .base import BaseConnector | |
| 29 | + | |
| 30 | +BASE = "https://beaulieugroupeimmobilier.com" | |
| 31 | +LIST_URL = f"{BASE}/batiment/?category=residentiel" | |
| 32 | + | |
| 33 | +# « 418 Alcide-C.-Horth, app #206 | 4 1/2 » -> adresse, n° app, type | |
| 34 | +_UNIT_RE = re.compile(r"^(.*?),?\s*app\s*#\s*(\w+)\s*(?:\|\s*(.+))?$", re.I) | |
| 35 | +_BG_IMG_RE = re.compile(r"url\(\s*['\"]?([^'\")]+)['\"]?\s*\)") | |
| 36 | +_AREA_RE = re.compile(r"superficie\s*:?\s*([\d\s,]+)\s*pi", re.I) | |
| 37 | + | |
| 38 | + | |
| 39 | +class BeaulieuGroupeConnector(BaseConnector): | |
| 40 | + source_id = "beaulieu_groupe" | |
| 41 | + request_delay = 0.6 | |
| 42 | + max_detail = 12 # plafond de fiches visitées par sync | |
| 43 | + | |
| 44 | + def fetch(self) -> list[Listing]: | |
| 45 | + soup = BeautifulSoup(self.get(LIST_URL).text, "html.parser") | |
| 46 | + | |
| 47 | + listings: dict[str, Listing] = {} | |
| 48 | + visited = 0 | |
| 49 | + for card in soup.select("div.card-batiment"): | |
| 50 | + link = card.select_one('a[href*="/batiment/"]') | |
| 51 | + if link is None: | |
| 52 | + continue | |
| 53 | + url = link["href"].strip() | |
| 54 | + status = "" | |
| 55 | + for p in card.select("p"): | |
| 56 | + t = p.get_text(" ", strip=True) | |
| 57 | + if t and "VOIR LA FICHE" not in t.upper(): | |
| 58 | + status = t | |
| 59 | + break | |
| 60 | + # les immeubles COMPLET n'ont aucune unité libre : fiche non visitée | |
| 61 | + if "COMPLET" in status.upper() or visited >= self.max_detail: | |
| 62 | + continue | |
| 63 | + visited += 1 | |
| 64 | + try: | |
| 65 | + self._parse_building(url, listings) | |
| 66 | + except Exception: | |
| 67 | + continue | |
| 68 | + return list(listings.values()) | |
| 69 | + | |
| 70 | + # -- fiche d'immeuble (/batiment/<slug>/) ------------------------------------- | |
| 71 | + def _parse_building(self, url: str, listings: dict[str, Listing]) -> None: | |
| 72 | + m = re.search(r"/batiment/([^/?#]+)", url) | |
| 73 | + if not m: | |
| 74 | + return | |
| 75 | + slug = m.group(1) | |
| 76 | + soup = BeautifulSoup(self.get(url).text, "html.parser") | |
| 77 | + | |
| 78 | + # description de l'immeuble (paragraphes Elementor avant la liste) | |
| 79 | + paras = [p.get_text(" ", strip=True) | |
| 80 | + for p in soup.select(".elementor-widget-text-editor p")] | |
| 81 | + description = "\n".join(t for t in paras if len(t) >= 40)[:1200] | |
| 82 | + | |
| 83 | + for card in soup.select("div.card-local"): | |
| 84 | + h5 = card.select_one("h5") | |
| 85 | + info = card.select_one("p.card-text") | |
| 86 | + if h5 is None: | |
| 87 | + continue | |
| 88 | + head = re.sub(r"\s+", " ", h5.get_text(" ", strip=True)) | |
| 89 | + info_txt = re.sub( | |
| 90 | + r"\s+", " ", info.get_text(" ", strip=True)) if info else "" | |
| 91 | + if re.search(r"(?i)indisponible", info_txt): | |
| 92 | + continue # unité louée | |
| 93 | + | |
| 94 | + m_unit = _UNIT_RE.match(head) | |
| 95 | + if not m_unit: | |
| 96 | + continue # carte sans n° d'app (bandeau) | |
| 97 | + addr, app_no, type_txt = (m_unit.group(1).strip(), | |
| 98 | + m_unit.group(2), | |
| 99 | + (m_unit.group(3) or "").strip()) | |
| 100 | + | |
| 101 | + ext_id = f"{slug}--app-{app_no.lower()}" | |
| 102 | + if ext_id in listings: | |
| 103 | + continue | |
| 104 | + | |
| 105 | + area = None | |
| 106 | + m_area = _AREA_RE.search(info_txt) | |
| 107 | + if m_area: | |
| 108 | + try: | |
| 109 | + area = float(m_area.group(1).replace(" ", "") | |
| 110 | + .replace(" ", "").replace(",", ".")) | |
| 111 | + except ValueError: | |
| 112 | + area = None | |
| 113 | + | |
| 114 | + images: list[str] = [] | |
| 115 | + img = card.select_one(".card-img-top") | |
| 116 | + if img is not None: | |
| 117 | + m_bg = _BG_IMG_RE.search(img.get("style", "")) | |
| 118 | + if m_bg and m_bg.group(1).startswith("http") \ | |
| 119 | + and "Complet" not in m_bg.group(1): | |
| 120 | + images.append(m_bg.group(1)) | |
| 121 | + | |
| 122 | + listings[ext_id] = Listing( | |
| 123 | + source=self.source_id, | |
| 124 | + external_id=ext_id, | |
| 125 | + url=url, | |
| 126 | + title=head, | |
| 127 | + address=f"{addr}, Rimouski" if addr else "", | |
| 128 | + city="Rimouski", # tout le parc affiché est à Rimouski | |
| 129 | + unit_type=normalize_unit_type(type_txt), | |
| 130 | + price=None, # aucun loyer publié sur le site | |
| 131 | + price_label="", | |
| 132 | + availability="", # libre = non « Indisponible » ; pas de date publiée | |
| 133 | + area_sqft=area, | |
| 134 | + description=description, | |
| 135 | + images=images, | |
| 136 | + ) | |
added
louka/connectors/gestion_jdm.py
+156 −0
@@ -0,0 +1,156 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/gestion_jdm.py : connecteur JDM Gestion immobilière (gestionjdm.ca | |
| 5 | +# — Rivière-du-Loup, Témiscouata-sur-le-Lac, Cacouna ; Bas-Saint-Laurent). | |
| 6 | +# WordPress + Elementor (widget UAEL Posts) rendu serveur : | |
| 7 | +# - page /logement/ : cartes `.uael-post-wrapper.logement` — badge | |
| 8 | +# d'étiquette (« Disponible »), adresse complète en extrait | |
| 9 | +# (« 36 rue plourde, Rivière-du-Loup (QC) G5R 1A1 »), photo, lien fiche ; | |
| 10 | +# - fiche (/logement-1/…) : sections h4 « Description » (text-editor), | |
| 11 | +# « Caractéristiques » et « À proximité » (icon-list), galerie. | |
| 12 | +# Granularité IMMEUBLE (« 24 unités de logements sur 3 étages ») — précédent | |
| 13 | +# girs/immeubles_guillot : aucun prix ni liste d'unités publiés -> price=None, | |
| 14 | +# rien d'inventé. Les cartes étiquetées « loué / indisponible » (si elles | |
| 15 | +# apparaissent un jour) sont écartées ; le badge brut alimente availability. | |
| 16 | +# Le type n'est retenu que si UN seul est cité (« 3-1/2, 4-1/2 et 5-1/2 » -> | |
| 17 | +# vide, la description fait foi). Les pages /chambre/ (maisons de chambres) | |
| 18 | +# et /commercial/ ne sont pas visitées. | |
| 19 | +# external_id stable : slug de la fiche (logement-1). | |
| 20 | +# robots.txt ouvert (Disallow vide), sitemap Yoast public. | |
| 21 | +# ----------------------------------------------------------------------------- | |
| 22 | +from __future__ import annotations | |
| 23 | + | |
| 24 | +import re | |
| 25 | + | |
| 26 | +from bs4 import BeautifulSoup | |
| 27 | + | |
| 28 | +from ..schema import Listing | |
| 29 | +from .base import BaseConnector | |
| 30 | + | |
| 31 | +BASE = "https://gestionjdm.ca" | |
| 32 | +LIST_URL = f"{BASE}/logement/" | |
| 33 | + | |
| 34 | +_TYPE_RE = re.compile(r"([1-9])\s*[-\s]?\s*(?:½|1/2)") | |
| 35 | +_CITY_RE = re.compile(r",\s*([^,()]+?)\s*\(QC\)", re.I) | |
| 36 | +_SKIP_STATUS = re.compile(r"(?i)lou[ée]|indisponible|non\s+disponible") | |
| 37 | + | |
| 38 | + | |
| 39 | +class GestionJdmConnector(BaseConnector): | |
| 40 | + source_id = "gestion_jdm" | |
| 41 | + request_delay = 0.6 | |
| 42 | + max_images = 10 | |
| 43 | + | |
| 44 | + def fetch(self) -> list[Listing]: | |
| 45 | + soup = BeautifulSoup(self.get(LIST_URL).text, "html.parser") | |
| 46 | + listings: dict[str, Listing] = {} | |
| 47 | + for card in soup.select("div.uael-post-wrapper"): | |
| 48 | + try: | |
| 49 | + self._parse_card(card, listings) | |
| 50 | + except Exception: | |
| 51 | + continue | |
| 52 | + return list(listings.values()) | |
| 53 | + | |
| 54 | + # -- carte UAEL Posts ---------------------------------------------------------- | |
| 55 | + def _parse_card(self, card, listings: dict[str, Listing]) -> None: | |
| 56 | + link = card.select_one("a[href]") | |
| 57 | + if link is None: | |
| 58 | + return | |
| 59 | + url = link["href"].strip() | |
| 60 | + m = re.search(r"gestionjdm\.ca/([^/?#]+)", url) | |
| 61 | + if not m: | |
| 62 | + return | |
| 63 | + ext_id = m.group(1) | |
| 64 | + if ext_id in listings: | |
| 65 | + return | |
| 66 | + | |
| 67 | + # adresse complète en extrait (« 52 rue de l'Anse, Témiscouata… (QC) G0L 1E0 ») | |
| 68 | + excerpt = card.select_one(".uael-post__excerpt") | |
| 69 | + address = re.sub(r"\s+", " ", | |
| 70 | + excerpt.get_text(" ", strip=True)) if excerpt else "" | |
| 71 | + m_city = _CITY_RE.search(address) | |
| 72 | + city = m_city.group(1).strip() if m_city else "" | |
| 73 | + | |
| 74 | + # badge d'étiquette (« Disponible ») — brut ; cartes louées écartées | |
| 75 | + badge = card.select_one(".uael-post__terms") | |
| 76 | + availability = re.sub(r"\s+", " ", | |
| 77 | + badge.get_text(" ", strip=True)) if badge else "" | |
| 78 | + if _SKIP_STATUS.search(availability): | |
| 79 | + return | |
| 80 | + | |
| 81 | + images: list[str] = [] | |
| 82 | + thumb = card.select_one(".uael-post__thumbnail img[src]") | |
| 83 | + if thumb and thumb["src"].startswith("http"): | |
| 84 | + images.append(thumb["src"]) | |
| 85 | + | |
| 86 | + # fiche (cache : re-fetch seulement si adresse/badge changent) | |
| 87 | + payload = self.detail(ext_id, f"{address}|{availability}", | |
| 88 | + lambda: self._fetch_detail(url)) | |
| 89 | + description = payload.get("description", "") | |
| 90 | + amenities = payload.get("amenities", []) | |
| 91 | + for u in payload.get("images", []): | |
| 92 | + if u not in images and len(images) < self.max_images: | |
| 93 | + images.append(u) | |
| 94 | + | |
| 95 | + # type d'unités : seulement si UN seul type est cité en prose | |
| 96 | + types = sorted(set(_TYPE_RE.findall(f"{description} " | |
| 97 | + f"{' '.join(amenities)}"))) | |
| 98 | + unit_type = f"{types[0]}½" if len(types) == 1 else "" | |
| 99 | + | |
| 100 | + listings[ext_id] = Listing( | |
| 101 | + source=self.source_id, | |
| 102 | + external_id=ext_id, | |
| 103 | + url=url, | |
| 104 | + title=address or ext_id, | |
| 105 | + address=address, | |
| 106 | + city=city, | |
| 107 | + unit_type=unit_type, | |
| 108 | + price=None, # aucun loyer publié sur le site | |
| 109 | + price_label="", | |
| 110 | + availability=availability, | |
| 111 | + description=description, | |
| 112 | + amenities=amenities, | |
| 113 | + images=images, | |
| 114 | + ) | |
| 115 | + | |
| 116 | + # -- fiche (/logement-<n>/) ------------------------------------------------------ | |
| 117 | + def _fetch_detail(self, url: str) -> dict: | |
| 118 | + soup = BeautifulSoup(self.get(url).text, "html.parser") | |
| 119 | + out: dict = {"description": "", "amenities": [], "images": []} | |
| 120 | + | |
| 121 | + paras: list[str] = [] | |
| 122 | + nearby: list[str] = [] | |
| 123 | + for h4 in soup.select("h4.elementor-heading-title"): | |
| 124 | + section = h4.get_text(" ", strip=True).lower() | |
| 125 | + widget = h4.find_parent("div", class_="elementor-widget-heading") | |
| 126 | + nxt = widget.find_next_sibling("div") if widget else None | |
| 127 | + if nxt is None: | |
| 128 | + continue | |
| 129 | + if section.startswith("description"): | |
| 130 | + paras += [re.sub(r"\s+", " ", p.get_text(" ", strip=True)) | |
| 131 | + for p in nxt.select("p")] | |
| 132 | + if not nxt.select("p"): # texte sans <p> | |
| 133 | + paras.append(re.sub(r"\s+", " ", | |
| 134 | + nxt.get_text(" ", strip=True))) | |
| 135 | + elif section.startswith("caractéristiques"): | |
| 136 | + out["amenities"] = [ | |
| 137 | + re.sub(r"\s+", " ", li.get_text(" ", strip=True)) | |
| 138 | + for li in nxt.select("li") if li.get_text(strip=True)] | |
| 139 | + elif section.startswith("à proximité"): | |
| 140 | + nearby = [re.sub(r"\s+", " ", li.get_text(" ", strip=True)) | |
| 141 | + for li in nxt.select("li") if li.get_text(strip=True)] | |
| 142 | + | |
| 143 | + description = "\n".join(t for t in paras if t)[:1400] | |
| 144 | + if nearby: | |
| 145 | + description = (description + "\nÀ proximité : " | |
| 146 | + + ", ".join(nearby)).strip() | |
| 147 | + out["description"] = description | |
| 148 | + | |
| 149 | + for img in soup.select("img[src*='/wp-content/uploads/']"): | |
| 150 | + u = img["src"] | |
| 151 | + if (u.startswith("http") and "logo" not in u.lower() | |
| 152 | + and u not in out["images"]): | |
| 153 | + out["images"].append(u) | |
| 154 | + if len(out["images"]) >= self.max_images: | |
| 155 | + break | |
| 156 | + return out | |
added
louka/connectors/immeubles_bois.py
+80 −0
@@ -0,0 +1,80 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/immeubles_bois.py : connecteur Les Immeubles Bois (immeublesbois.ca | |
| 5 | +# — Rimouski, Bas-Saint-Laurent ; 320+ logements, 26 immeubles, 1½–5½). | |
| 6 | +# WordPress + Elementor rendu serveur : la page /residentiel/ liste un | |
| 7 | +# loop-item par immeuble (adresse en h6 + photo aérienne + lien fiche). | |
| 8 | +# Les fiches d'immeuble ne publient RIEN de plus (titre + « Demander une | |
| 9 | +# visite » + vignettes d'AUTRES immeubles) : elles ne sont pas visitées — | |
| 10 | +# 1 seule requête par sync. Granularité IMMEUBLE assumée (précédent girs / | |
| 11 | +# immeubles_guillot) : aucun prix, aucune unité, aucune disponibilité | |
| 12 | +# publiés -> price=None, availability vide, rien d'inventé. Tout le parc | |
| 13 | +# affiché est à Rimouski (coordonnées du siège : 40 rue Saint-Germain Est). | |
| 14 | +# external_id stable : slug de la fiche (/157-159-rue-hudon/). | |
| 15 | +# robots.txt : Allow / pour les agents génériques (blocage ciblé de bots | |
| 16 | +# d'entraînement IA ; Content-Signal search=yes, use=reference — usage | |
| 17 | +# agrégateur/référence avec lien retour cohérent, précédent place_florimay). | |
| 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://immeublesbois.ca" | |
| 29 | +LIST_URL = f"{BASE}/residentiel/" | |
| 30 | + | |
| 31 | + | |
| 32 | +class ImmeublesBoisConnector(BaseConnector): | |
| 33 | + source_id = "immeubles_bois" | |
| 34 | + request_delay = 0.6 | |
| 35 | + | |
| 36 | + def fetch(self) -> list[Listing]: | |
| 37 | + soup = BeautifulSoup(self.get(LIST_URL).text, "html.parser") | |
| 38 | + | |
| 39 | + listings: dict[str, Listing] = {} | |
| 40 | + for item in soup.select( | |
| 41 | + '[data-elementor-type="loop-item"].category-residentiel'): | |
| 42 | + try: | |
| 43 | + self._parse_item(item, listings) | |
| 44 | + except Exception: | |
| 45 | + continue | |
| 46 | + return list(listings.values()) | |
| 47 | + | |
| 48 | + # -- loop-item Elementor (un immeuble) ---------------------------------------- | |
| 49 | + def _parse_item(self, item, listings: dict[str, Listing]) -> None: | |
| 50 | + link = item.select_one("h6 a[href]") | |
| 51 | + if link is None: | |
| 52 | + return | |
| 53 | + url = link["href"].strip() | |
| 54 | + title = re.sub(r"\s+", " ", link.get_text(" ", strip=True)) | |
| 55 | + m = re.search(r"immeublesbois\.ca/([^/?#]+)", url) | |
| 56 | + if not m or not title: | |
| 57 | + return | |
| 58 | + ext_id = m.group(1) | |
| 59 | + if ext_id in listings: | |
| 60 | + return | |
| 61 | + | |
| 62 | + images: list[str] = [] | |
| 63 | + img = item.select_one("img[src]") | |
| 64 | + if img and img["src"].startswith("http") \ | |
| 65 | + and "logo" not in img["src"].lower(): | |
| 66 | + images.append(img["src"]) | |
| 67 | + | |
| 68 | + listings[ext_id] = Listing( | |
| 69 | + source=self.source_id, | |
| 70 | + external_id=ext_id, | |
| 71 | + url=url, | |
| 72 | + title=title, | |
| 73 | + address=f"{title}, Rimouski", | |
| 74 | + city="Rimouski", # tout le parc affiché est à Rimouski | |
| 75 | + unit_type="", # typologies non publiées par immeuble | |
| 76 | + price=None, # aucun loyer publié sur le site | |
| 77 | + price_label="", | |
| 78 | + availability="", # disponibilités hors ligne (téléphone) | |
| 79 | + images=images, | |
| 80 | + ) | |
added
louka/connectors/louer_saguenay.py
+161 −0
@@ -0,0 +1,161 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/louer_saguenay.py : connecteur Louer Saguenay (louersaguenay.com | |
| 5 | +# — centre-ville de Jonquière, ville de Saguenay ; 9128-0768 Québec inc.). | |
| 6 | +# Petit parc de lofts et d'appartements au-dessus de locaux commerciaux de la | |
| 7 | +# rue Saint-Dominique. WordPress + Elementor rendu serveur : | |
| 8 | +# - page /location-loft-appartement-jonquiere/ : boucle Elementor — un | |
| 9 | +# item `.e-loop-item.category-location-loft-appartement-jonquiere` par | |
| 10 | +# annonce (titre h2 + lien, extrait « DISPONIBLE AU 1-6-2026 », photo) ; | |
| 11 | +# la section « NOS ESPACES COMMERCIALES » (articles `ee-post`, catégorie | |
| 12 | +# location-espaces-commercials-saguenay) est ignorée ; | |
| 13 | +# - fiche : description (« Informations »), « Prix mensuel : » suivi des | |
| 14 | +# tarifs « 1395.00$ (Non meublé) / 1495.00$ (Meublé) » -> price = le plus | |
| 15 | +# bas, price_label = texte original complet, galerie de photos. | |
| 16 | +# Fiches visitées via self.detail (cache BD, re-fetch seulement si l'extrait | |
| 17 | +# ou le titre change) — parc ~5 unités résidentielles. | |
| 18 | +# external_id stable : slug de la fiche. city=Saguenay, sector=Jonquière | |
| 19 | +# (convention des connecteurs SLSJ existants). | |
| 20 | +# robots.txt ouvert (Disallow /imunify-bot-check seulement), sitemap Yoast. | |
| 21 | +# ----------------------------------------------------------------------------- | |
| 22 | +from __future__ import annotations | |
| 23 | + | |
| 24 | +import re | |
| 25 | + | |
| 26 | +from bs4 import BeautifulSoup | |
| 27 | + | |
| 28 | +from ..schema import Listing, normalize_unit_type | |
| 29 | +from .base import BaseConnector | |
| 30 | + | |
| 31 | +BASE = "https://louersaguenay.com" | |
| 32 | +LIST_URL = f"{BASE}/location-loft-appartement-jonquiere/" | |
| 33 | + | |
| 34 | +_TYPE_RE = re.compile(r"([1-6])\s*(?:½|1/2)") | |
| 35 | +# « … à louer 2345 rue Saint-Dominique Suite 201 » -> tout ce qui suit « à louer » | |
| 36 | +_ADDR_RE = re.compile(r"à louer\s+(\d.+)$", re.I) | |
| 37 | +_PRICE_RE = re.compile(r"(\d[\d\s]*(?:[.,]\d{1,2})?)\s*\$") | |
| 38 | + | |
| 39 | + | |
| 40 | +class LouerSaguenayConnector(BaseConnector): | |
| 41 | + source_id = "louer_saguenay" | |
| 42 | + request_delay = 0.6 | |
| 43 | + max_images = 10 | |
| 44 | + | |
| 45 | + def fetch(self) -> list[Listing]: | |
| 46 | + soup = BeautifulSoup(self.get(LIST_URL).text, "html.parser") | |
| 47 | + | |
| 48 | + listings: dict[str, Listing] = {} | |
| 49 | + items = soup.select( | |
| 50 | + ".e-loop-item.category-location-loft-appartement-jonquiere") | |
| 51 | + for item in items: | |
| 52 | + try: | |
| 53 | + self._parse_item(item, listings) | |
| 54 | + except Exception: | |
| 55 | + continue | |
| 56 | + return list(listings.values()) | |
| 57 | + | |
| 58 | + # -- item de la boucle Elementor ---------------------------------------------- | |
| 59 | + def _parse_item(self, item, listings: dict[str, Listing]) -> None: | |
| 60 | + h2 = item.select_one("h2.elementor-heading-title a[href]") | |
| 61 | + if h2 is None: | |
| 62 | + return | |
| 63 | + url = h2["href"].strip() | |
| 64 | + title = re.sub(r"\s+", " ", h2.get_text(" ", strip=True)) | |
| 65 | + m = re.search(r"louersaguenay\.com/([^/?#]+)", url) | |
| 66 | + if not m: | |
| 67 | + return | |
| 68 | + ext_id = m.group(1) | |
| 69 | + if ext_id in listings: | |
| 70 | + return | |
| 71 | + | |
| 72 | + # extrait = disponibilité (« DISPONIBLE AU 1-6-2026 ») quand publiée | |
| 73 | + availability = "" | |
| 74 | + excerpt = item.select_one(".elementor-widget-theme-post-excerpt " | |
| 75 | + ".elementor-widget-container") | |
| 76 | + if excerpt is not None: | |
| 77 | + t = re.sub(r"\s+", " ", excerpt.get_text(" ", strip=True)) | |
| 78 | + if t: | |
| 79 | + availability = t | |
| 80 | + | |
| 81 | + images: list[str] = [] | |
| 82 | + thumb = item.select_one("img[src]") | |
| 83 | + if thumb and thumb["src"].startswith("http"): | |
| 84 | + images.append(thumb["src"]) | |
| 85 | + | |
| 86 | + m_type = _TYPE_RE.search(title) | |
| 87 | + if m_type: | |
| 88 | + unit_type = f"{m_type.group(1)}½" | |
| 89 | + elif re.search(r"(?i)\bloft\b", title): | |
| 90 | + unit_type = "Loft" | |
| 91 | + else: | |
| 92 | + unit_type = "" | |
| 93 | + | |
| 94 | + m_addr = _ADDR_RE.search(title) | |
| 95 | + address = f"{m_addr.group(1)}, Jonquière" if m_addr else "" | |
| 96 | + | |
| 97 | + # fiche (cache : re-fetch seulement si le titre/extrait change) | |
| 98 | + payload = self.detail(ext_id, f"{title}|{availability}", | |
| 99 | + lambda: self._fetch_detail(url)) | |
| 100 | + | |
| 101 | + price_label = payload.get("price_label", "") | |
| 102 | + prices = [float(p.replace(" ", "").replace(" ", "").replace(",", ".")) | |
| 103 | + for p in _PRICE_RE.findall(price_label)] | |
| 104 | + prices = [p for p in prices if 100 <= p <= 20000] | |
| 105 | + | |
| 106 | + for u in payload.get("images", []): | |
| 107 | + if u not in images and len(images) < self.max_images: | |
| 108 | + images.append(u) | |
| 109 | + | |
| 110 | + listings[ext_id] = Listing( | |
| 111 | + source=self.source_id, | |
| 112 | + external_id=ext_id, | |
| 113 | + url=url, | |
| 114 | + title=title, | |
| 115 | + address=address, | |
| 116 | + sector="Jonquière", | |
| 117 | + city="Saguenay", | |
| 118 | + unit_type=normalize_unit_type(unit_type), | |
| 119 | + price=min(prices) if prices else None, # le plus bas (non meublé) | |
| 120 | + price_label=price_label, | |
| 121 | + availability=availability or payload.get("availability", ""), | |
| 122 | + description=payload.get("description", ""), | |
| 123 | + images=images, | |
| 124 | + ) | |
| 125 | + | |
| 126 | + # -- fiche ------------------------------------------------------------------- | |
| 127 | + def _fetch_detail(self, url: str) -> dict: | |
| 128 | + soup = BeautifulSoup(self.get(url).text, "html.parser") | |
| 129 | + out: dict = {"price_label": "", "availability": "", | |
| 130 | + "description": "", "images": []} | |
| 131 | + | |
| 132 | + # « Prix mensuel : » -> le heading suivant porte les tarifs | |
| 133 | + headings = soup.select("h1, h2.elementor-heading-title") | |
| 134 | + for i, h in enumerate(headings): | |
| 135 | + t = re.sub(r"\s+", " ", h.get_text(" ", strip=True)) | |
| 136 | + if re.match(r"(?i)^prix\s+mensuel", t) and i + 1 < len(headings): | |
| 137 | + out["price_label"] = re.sub( | |
| 138 | + r"\s+", " ", headings[i + 1].get_text(" ", strip=True)) | |
| 139 | + elif re.match(r"(?i)^disponible", t): | |
| 140 | + # « Disponible : » suivi d'un heading date (« 1er juillet 2022 ») | |
| 141 | + if t.rstrip(":").strip().lower() == "disponible" \ | |
| 142 | + and i + 1 < len(headings): | |
| 143 | + nxt = re.sub(r"\s+", " ", | |
| 144 | + headings[i + 1].get_text(" ", strip=True)) | |
| 145 | + t = f"Disponible : {nxt}" | |
| 146 | + out["availability"] = t | |
| 147 | + | |
| 148 | + # paragraphes de la section « Informations » | |
| 149 | + paras = [re.sub(r"\s+", " ", p.get_text(" ", strip=True)) | |
| 150 | + for p in soup.select(".elementor-widget-text-editor p")] | |
| 151 | + out["description"] = "\n".join(t for t in paras if len(t) >= 25)[:1600] | |
| 152 | + | |
| 153 | + for img in soup.select("img[src*='/wp-content/uploads/']"): | |
| 154 | + u = img["src"] | |
| 155 | + if (u.startswith("http") and "logo" not in u.lower() | |
| 156 | + and not re.search(r"-\d+x\d+\.(?:jpe?g|png|webp)$", u) | |
| 157 | + and u not in out["images"]): | |
| 158 | + out["images"].append(u) | |
| 159 | + if len(out["images"]) >= self.max_images: | |
| 160 | + break | |
| 161 | + return out | |
added
louka/connectors/luxuor.py
+157 −0
@@ -0,0 +1,157 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/luxuor.py : connecteur Les Immeubles Luxuor | |
| 5 | +# (lesimmeublesluxuor.com — ville de Saguenay). Condos locatifs neufs haut de | |
| 6 | +# gamme rue des Golfeurs (arrondissement Chicoutimi, projets « 42 logements » | |
| 7 | +# et « 16 logements »), plex « Quadrex » et résidence étudiante « La | |
| 8 | +# Fabrique » (3821 rue de la Fabrique, Jonquière). Site Wix rendu serveur, | |
| 9 | +# 4 pages projet FIXES sans fiches d'unités ni prix ni disponibilités | |
| 10 | +# (visites par formulaire) -> 1 annonce « catalogue » par projet, granularité | |
| 11 | +# PROJET (précédent girs/immeubles_guillot/alias_appartements), price=None, | |
| 12 | +# rien d'inventé. Texte extrait des blocs Wix `div[data-testid= | |
| 13 | +# "richTextElement"]` du <main> (les menus, légendes de galerie et pieds de | |
| 14 | +# page sont hors de ces blocs) : lignes courtes = pictogrammes de commodités | |
| 15 | +# (« Ascenseur », « Gym »…), lignes longues = description. Types multiples | |
| 16 | +# (3½-5½) cités en prose -> unit_type vide, la description fait foi. | |
| 17 | +# external_id stable : slug de page. robots.txt Wix standard (ouvert). | |
| 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.lesimmeublesluxuor.com" | |
| 29 | + | |
| 30 | +# (slug, secteur affiché par la page, extraire les pictogrammes ?) | |
| 31 | +PAGES = [ | |
| 32 | + ("42logements", "Chicoutimi", True), | |
| 33 | + ("16logements", "Chicoutimi", True), | |
| 34 | + ("quadrex", "", True), | |
| 35 | + ("lafabrique", "Jonquière", False), # sections Chambre 1/Cuisine… : prose | |
| 36 | +] | |
| 37 | + | |
| 38 | +_ADDR_RE = re.compile(r"\d{2,5},?\s+rue de la Fabrique[^,]*,\s*Jonquière" | |
| 39 | + r"(?:,\s*QC(?:\s+\w\d\w\s*\d\w\d)?)?", re.I) | |
| 40 | +_GOLFEURS_RE = re.compile(r"rue des Golfeurs", re.I) | |
| 41 | +_TYPE_RE = re.compile(r"([1-6])\s*(?:½|1/2)") | |
| 42 | +# lignes courtes à ne PAS traiter comme commodités (titres, CTA…) | |
| 43 | +_SKIP_LINE = re.compile( | |
| 44 | + r"(?i)^(photos?\s*&\s*plans?|un projet qui|réservez|laissez|découvrez|" | |
| 45 | + r"venez|nos logements|type de logement|contact)", re.I) | |
| 46 | + | |
| 47 | + | |
| 48 | +def _full_size(url: str) -> str: | |
| 49 | + """URL wixstatic redimensionnée -> média pleine taille (coupe au /v1/).""" | |
| 50 | + return url.split("/v1/")[0] if "/v1/" in url else url | |
| 51 | + | |
| 52 | + | |
| 53 | +class LuxuorConnector(BaseConnector): | |
| 54 | + source_id = "luxuor" | |
| 55 | + request_delay = 0.6 | |
| 56 | + max_images = 10 | |
| 57 | + | |
| 58 | + def fetch(self) -> list[Listing]: | |
| 59 | + listings: list[Listing] = [] | |
| 60 | + for slug, sector, with_amenities in PAGES: | |
| 61 | + try: | |
| 62 | + lst = self._parse_page(slug, sector, with_amenities) | |
| 63 | + except Exception: | |
| 64 | + continue | |
| 65 | + if lst is not None: | |
| 66 | + listings.append(lst) | |
| 67 | + return listings | |
| 68 | + | |
| 69 | + # -- page projet Wix ----------------------------------------------------------- | |
| 70 | + def _parse_page(self, slug: str, sector: str, | |
| 71 | + with_amenities: bool) -> Listing | None: | |
| 72 | + url = f"{BASE}/{slug}" | |
| 73 | + soup = BeautifulSoup(self.get(url).text, "html.parser") | |
| 74 | + main = soup.select_one("main") or soup.body | |
| 75 | + if main is None: | |
| 76 | + return None | |
| 77 | + | |
| 78 | + blocks = main.select('div[data-testid="richTextElement"]') | |
| 79 | + if not blocks: | |
| 80 | + return None | |
| 81 | + | |
| 82 | + # lignes de texte au niveau des <p> (une commodité Wix par <p>) | |
| 83 | + lines: list[str] = [] | |
| 84 | + for blk in blocks: | |
| 85 | + ps = blk.select("p") | |
| 86 | + if ps: | |
| 87 | + lines += [re.sub(r"\s+", " ", p.get_text(" ", strip=True)) | |
| 88 | + .replace("", "").strip() for p in ps] | |
| 89 | + else: | |
| 90 | + lines.append(re.sub(r"\s+", " ", blk.get_text(" ", strip=True)) | |
| 91 | + .replace("", "").strip()) | |
| 92 | + lines = [t for t in lines if t] | |
| 93 | + if not lines: | |
| 94 | + return None | |
| 95 | + | |
| 96 | + # titre = premier(s) bloc(s) court(s) (« 42 LOGEMENTS », « Résidence | |
| 97 | + # étudiante / LA FABRIQUE ») | |
| 98 | + head = re.sub(r"\s+", " ", blocks[0].get_text(" ", strip=True) | |
| 99 | + ).replace("", "").strip() | |
| 100 | + if slug == "lafabrique" and len(blocks) > 1: | |
| 101 | + head = f"{head} {blocks[1].get_text(' ', strip=True)}".strip() | |
| 102 | + name = head.title() if head.isupper() else head | |
| 103 | + | |
| 104 | + paras: list[str] = [] | |
| 105 | + amenities: list[str] = [] | |
| 106 | + for t in lines: | |
| 107 | + if t == head or _SKIP_LINE.match(t): | |
| 108 | + continue | |
| 109 | + if len(t) >= 45: | |
| 110 | + if len(paras) < 10: | |
| 111 | + paras.append(t) | |
| 112 | + elif (with_amenities and 3 <= len(t) < 45 | |
| 113 | + and not t.endswith((".", "!", "?", ":")) | |
| 114 | + and t not in amenities): | |
| 115 | + amenities.append(t) | |
| 116 | + description = "\n".join(paras)[:1600] | |
| 117 | + if not description and not amenities: | |
| 118 | + return None # page méconnaissable | |
| 119 | + | |
| 120 | + text = " ".join(lines) | |
| 121 | + m_addr = _ADDR_RE.search(text) | |
| 122 | + if m_addr: | |
| 123 | + address = m_addr.group(0) | |
| 124 | + elif _GOLFEURS_RE.search(text): | |
| 125 | + address = "Rue des Golfeurs, Chicoutimi" | |
| 126 | + else: | |
| 127 | + address = "" | |
| 128 | + | |
| 129 | + # type d'unités : seulement si UN seul type est cité en prose | |
| 130 | + types = sorted(set(_TYPE_RE.findall(text))) | |
| 131 | + unit_type = f"{types[0]}½" if len(types) == 1 else "" | |
| 132 | + | |
| 133 | + images: list[str] = [] | |
| 134 | + for img in main.select("img[src]"): | |
| 135 | + u = _full_size(img["src"]) | |
| 136 | + if (u.startswith("https://static.wixstatic.com") | |
| 137 | + and not u.lower().endswith(".svg") and u not in images): | |
| 138 | + images.append(u) | |
| 139 | + if len(images) >= self.max_images: | |
| 140 | + break | |
| 141 | + | |
| 142 | + return Listing( | |
| 143 | + source=self.source_id, | |
| 144 | + external_id=slug, | |
| 145 | + url=url, | |
| 146 | + title=f"Les Immeubles Luxuor — {name}", | |
| 147 | + address=address, | |
| 148 | + sector=sector, | |
| 149 | + city="Saguenay", # tous les projets du site sont à Ville Saguenay | |
| 150 | + unit_type=unit_type, | |
| 151 | + price=None, # aucun loyer publié sur le site | |
| 152 | + price_label="", | |
| 153 | + availability="", # visites par formulaire : rien d'inventé | |
| 154 | + description=description, | |
| 155 | + amenities=amenities[:30], | |
| 156 | + images=images, | |
| 157 | + ) | |
added
louka/connectors/quartier_plus.py
+146 −0
@@ -0,0 +1,146 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/quartier_plus.py : connecteur Le Quartier Plus (lequartierplus.com | |
| 5 | +# — Thetford Mines, Chaudière-Appalaches). Complexe locatif 55+ autonome | |
| 6 | +# (aucun service de repas/soins : appartements standards, lofts urbains et | |
| 7 | +# unités bigénérationnelles) construit en 3 phases ; la page | |
| 8 | +# /plans-et-visites-virtuelles/ embarque un sélecteur d'étages WordPress | |
| 9 | +# (thème ubeo-theme-v5-child) dont TOUTES les unités sont rendues serveur : | |
| 10 | +# blocs `div.unit#unit_<n>` avec numéro, statut (« disponible », « Construction | |
| 11 | +# 2026 »…), type (3½/4½/4½+/5½), lettre de modèle, superficie du logement et | |
| 12 | +# du balcon, plan PNG/PDF. Seules les unités dont le statut contient | |
| 13 | +# « disponible » sont retenues. Aucun prix par unité (seul un « à partir de | |
| 14 | +# 1195 $ » global est cité en prose) -> price=None, rien d'inventé. | |
| 15 | +# L'adresse « 140, rue Kingsville » (phase 3) publiée sur la page n'est | |
| 16 | +# attachée qu'aux unités dont le statut mentionne « Construction 2026 ». | |
| 17 | +# external_id stable : numéro d'unité (unit_501 -> 501). | |
| 18 | +# robots.txt Yoast ouvert (Disallow vide), sitemap public. | |
| 19 | +# ----------------------------------------------------------------------------- | |
| 20 | +from __future__ import annotations | |
| 21 | + | |
| 22 | +import re | |
| 23 | + | |
| 24 | +from bs4 import BeautifulSoup | |
| 25 | + | |
| 26 | +from ..schema import Listing, normalize_unit_type | |
| 27 | +from .base import BaseConnector | |
| 28 | + | |
| 29 | +BASE = "https://www.lequartierplus.com" | |
| 30 | +LIST_URL = f"{BASE}/plans-et-visites-virtuelles/" | |
| 31 | + | |
| 32 | +# adresse de la phase 3, telle que publiée sur la page (bloc « Phase 3 ») | |
| 33 | +_PHASE3_ADDR = "140, rue Kingsville" | |
| 34 | + | |
| 35 | +_AREA_RE = re.compile(r"([\d\s,]+)\s*p") | |
| 36 | + | |
| 37 | + | |
| 38 | +def _sqft(text: str) -> float | None: | |
| 39 | + """« 1 097 p2 » -> 1097.0 (None si illisible).""" | |
| 40 | + m = _AREA_RE.search(text) | |
| 41 | + if not m: | |
| 42 | + return None | |
| 43 | + try: | |
| 44 | + return float(m.group(1).replace(" ", "").replace(" ", "") | |
| 45 | + .replace(",", ".")) | |
| 46 | + except ValueError: | |
| 47 | + return None | |
| 48 | + | |
| 49 | + | |
| 50 | +class QuartierPlusConnector(BaseConnector): | |
| 51 | + source_id = "quartier_plus" | |
| 52 | + request_delay = 0.6 | |
| 53 | + | |
| 54 | + def fetch(self) -> list[Listing]: | |
| 55 | + html = self.get(LIST_URL).text | |
| 56 | + soup = BeautifulSoup(html, "html.parser") | |
| 57 | + | |
| 58 | + listings: dict[str, Listing] = {} | |
| 59 | + for unit in soup.select("div.unit[id^=unit_]"): | |
| 60 | + try: | |
| 61 | + self._parse_unit(unit, listings) | |
| 62 | + except Exception: | |
| 63 | + continue | |
| 64 | + return list(listings.values()) | |
| 65 | + | |
| 66 | + # -- bloc unité (div.unit#unit_<n>) ------------------------------------------ | |
| 67 | + def _parse_unit(self, unit, listings: dict[str, Listing]) -> None: | |
| 68 | + ext_id = unit.get("id", "").removeprefix("unit_").strip() | |
| 69 | + if not ext_id or ext_id in listings: | |
| 70 | + return | |
| 71 | + | |
| 72 | + header = unit.select_one("p.details_header") | |
| 73 | + if header is None: | |
| 74 | + return | |
| 75 | + name_el = header.select_one("span.has-wpdc-x-large-font-size") | |
| 76 | + name = name_el.get_text(" ", strip=True) if name_el else f"Unité {ext_id}" | |
| 77 | + status_el = header.select_one("span.has-wpdc-medium-font-size") | |
| 78 | + availability = re.sub( | |
| 79 | + r"\s+", " ", | |
| 80 | + status_el.get_text(" ", strip=True)) if status_el else "" | |
| 81 | + | |
| 82 | + # seules les unités affichées « disponible » sont des annonces | |
| 83 | + if "disponible" not in availability.lower(): | |
| 84 | + return | |
| 85 | + if "indisponible" in availability.lower() or re.search( | |
| 86 | + r"(?i)lou[ée]|réserv", availability): | |
| 87 | + return | |
| 88 | + | |
| 89 | + # colonnes du bloc unit_details : type (badge), modèle, superficies | |
| 90 | + unit_label, model, area, balcony = "", "", None, None | |
| 91 | + for col in unit.select(".unit_details > div"): | |
| 92 | + txt = re.sub(r"\s+", " ", col.get_text(" ", strip=True)) | |
| 93 | + label = "" | |
| 94 | + small = col.select_one(".small_text") | |
| 95 | + if small: | |
| 96 | + label = small.get_text(" ", strip=True).upper() | |
| 97 | + if "SUPERFICIE" in label: | |
| 98 | + area = _sqft(txt) | |
| 99 | + elif "BALCON" in label: | |
| 100 | + balcony = _sqft(txt) | |
| 101 | + elif "TYPE" in label: | |
| 102 | + strong = col.select_one("strong") | |
| 103 | + model = strong.get_text(strip=True) if strong else "" | |
| 104 | + elif re.match(r"^[1-6]½", txt): | |
| 105 | + unit_label = txt | |
| 106 | + | |
| 107 | + amenities: list[str] = [] | |
| 108 | + if model: | |
| 109 | + amenities.append(f"Modèle {model}") | |
| 110 | + if balcony: | |
| 111 | + amenities.append(f"Balcon {balcony:g} pi²") | |
| 112 | + | |
| 113 | + # plan du logement (PNG du sélecteur) | |
| 114 | + images: list[str] = [] | |
| 115 | + plan = unit.select_one("p.unit_plan img[src]") | |
| 116 | + if plan: | |
| 117 | + u = plan["src"] | |
| 118 | + if u.startswith("/"): | |
| 119 | + u = BASE + u | |
| 120 | + if u.startswith("http"): | |
| 121 | + images.append(u) | |
| 122 | + | |
| 123 | + # l'adresse publiée de la phase 3 ne vaut que pour ses unités | |
| 124 | + address = "" | |
| 125 | + if "construction 2026" in availability.lower(): | |
| 126 | + address = f"{_PHASE3_ADDR}, Thetford Mines" | |
| 127 | + | |
| 128 | + title = f"Le Quartier Plus — {name}" | |
| 129 | + if unit_label: | |
| 130 | + title += f" ({unit_label})" | |
| 131 | + | |
| 132 | + listings[ext_id] = Listing( | |
| 133 | + source=self.source_id, | |
| 134 | + external_id=ext_id, | |
| 135 | + url=LIST_URL, | |
| 136 | + title=title, | |
| 137 | + address=address, | |
| 138 | + city="Thetford Mines", | |
| 139 | + unit_type=normalize_unit_type(unit_label.replace("+", "")), | |
| 140 | + price=None, # aucun loyer par unité publié | |
| 141 | + price_label="", | |
| 142 | + availability=availability, | |
| 143 | + area_sqft=area, | |
| 144 | + amenities=amenities, | |
| 145 | + images=images, | |
| 146 | + ) | |
added
reports/connectors/alias_appartements.md
+25 −0
@@ -0,0 +1,25 @@ | ||
| 1 | +# alias_appartements — Alias Appartements (Jonquière) | |
| 2 | +- site: https://www.aliasappartements.com | |
| 3 | +- méthode: html (1 requête par sync — page projet unique) | |
| 4 | +- annonces: 1 (annonce « catalogue » à granularité PROJET : 80 unités 4½, rue des Mouettes, Jonquière — Groupe Boudreault) | |
| 5 | +- couverture: titre/adresse 100% (h3 « Rue des Mouettes, Jonquière »), type 100% (« 4 1/2 »), prix plancher 100% (« À partir de 1575$ » → price=1575, price_from), caractéristiques 100% (carrousel JetElements, dont « Animaux de compagnie permis » → pets), description 100%, photos 100% ; disponibilité 0% (par formulaire/contact) | |
| 6 | +- fixture: ok · test: ok (2 verts) | |
| 7 | + | |
| 8 | +## Champs extraits | |
| 9 | +- Headings Elementor de la page d'accueil : « 80 unités » (spans, gardé en tête d'amenities), « 4 1/2 » (type), « À partir de 1575$ » (price_label brut ; `finalize()` en déduit le prix plancher + `price_from`), adresse `h3`. | |
| 10 | +- Caractéristiques : items du carrousel JetElements (`h5.jet-carousel__item-title`) — air climatisé, lave-vaisselle, balcon 13'×6', animaux permis, douche italienne… | |
| 11 | +- Description : paragraphes descriptifs ≥ 100 caractères (pied de page « © » exclu), plafond 5. | |
| 12 | +- Photos : `wp-content/uploads` hors pictogrammes SVG/`Group-N`, plafond 10. | |
| 13 | +- `external_id` = `rue-des-mouettes` (projet unique). `city=Saguenay`, `sector=Jonquière` (convention SLSJ). | |
| 14 | +- Garde-fou : si ni l'adresse ni « N unités » ne sont reconnaissables (refonte du site), le connecteur retourne `[]` plutôt que d'émettre une annonce dégradée. | |
| 15 | + | |
| 16 | +## Champs indisponibles à la source | |
| 17 | +- Disponibilités et fiches d'unités : jamais publiées (« Réservez votre appartement » → formulaire de contact) → `availability=""`, granularité projet assumée (précédent girs/immeubles_guillot). | |
| 18 | +- Le site parlait de « 48 unités » à l'étude (2026-08-08) et affiche « 80 unités » aujourd'hui : le nombre publié est repris tel quel en amenity. | |
| 19 | + | |
| 20 | +## Fragilités | |
| 21 | +- Page marketing Elementor : les valeurs (type, prix plancher) sont des headings sans classes sémantiques — un remaniement graphique peut nécessiter d'ajuster les motifs (regex strictes ^…$ pour éviter les faux positifs). | |
| 22 | +- Échéancier de phases (Phase 1-4, 2023-2025) présent sur la page mais non modélisé (pas des annonces). | |
| 23 | + | |
| 24 | +## Notes | |
| 25 | +- robots.txt Yoast ouvert (Disallow vide), sitemap public ; politesse 0,6 s, 1 requête par sync. | |
added
reports/connectors/beaulieu_groupe.md
+27 −0
@@ -0,0 +1,27 @@ | ||
| 1 | +# beaulieu_groupe — Beaulieu Groupe Immobilier / BGI (Rimouski) | |
| 2 | +- site: https://beaulieugroupeimmobilier.com/batiment/?category=residentiel | |
| 3 | +- méthode: html (archive + 1 fiche par immeuble non COMPLET — 2 requêtes au volume actuel) | |
| 4 | +- annonces: 4 (unités libres du 418, rue Alcide-C.-Horth, 24 logements neufs en construction pour le 1er juillet ; les 4 autres immeubles résidentiels sont COMPLET) | |
| 5 | +- couverture: titre 100%, adresse 100%, type 100% (« | 4 1/2 » du titre de carte), superficie 100% (« Superficie : N pi² »), photo 100%, description d'immeuble 100% ; prix 0% (jamais publié), date de disponibilité 0% (non publiée) | |
| 6 | +- fixture: ok · test: ok (2 verts) | |
| 7 | + | |
| 8 | +## Champs extraits | |
| 9 | +- Archive `/batiment/?category=residentiel` : cartes `div.card-batiment` (nom `h5`, statut `p` = « COMPLET » ou « Locaux disponibles : N », lien fiche). Le filtre catégorie est rendu serveur ; la pagination `/batiment/page/2/` ne concerne que l'archive non filtrée (vérifié : mêmes 5 cartes). | |
| 10 | +- Fiche `/batiment/<slug>/` : cartes d'unités `div.card-local` — `h5` « 418 Alcide-C.-Horth, app #206 | 4 1/2 », `p.card-text` « Indisponible » (image générique Complet.png) pour les unités louées, « Superficie : N pi² » + photo réelle pour les libres. Seules les unités **non « Indisponible »** sont émises. | |
| 11 | +- Politesse : les fiches des immeubles marqués COMPLET ne sont **pas visitées** (toutes leurs unités sont indisponibles) ; plafond `max_detail=12` fiches par sync. | |
| 12 | +- `external_id` = slug de fiche + n° d'app (`418-rue-alcide-c-horth--app-206`) — stable. | |
| 13 | +- Ville : tout le parc affiché est à Rimouski (rue Laval-Nord, Sainte-Thérèse, Tanguay, Alcide-C.-Horth, Simone-Belzile). | |
| 14 | + | |
| 15 | +## Champs indisponibles à la source | |
| 16 | +- Prix : jamais publié → `price=None`, rien d'inventé. | |
| 17 | +- Disponibilité : pas de texte par unité (le statut « libre » est l'absence d'« Indisponible » + le compteur « Locaux disponibles : N » de l'archive) → `availability=""`. | |
| 18 | +- Le bandeau « 24 LOGEMENTS NEUFS POUR LE 1ER JUILLET » est un lien promotionnel global, pas une date par unité. | |
| 19 | + | |
| 20 | +## Fragilités | |
| 21 | +- Shortcode maison avec styles inline (pas de classes sémantiques au-delà de `card-batiment`/`card-local`) : un remaniement du thème casserait les sélecteurs. | |
| 22 | +- La détection « libre » repose sur l'absence du mot « Indisponible » ; si le site adopte un autre libellé (« Loué »… ), ajouter le motif. | |
| 23 | +- Ne pas confondre avec `immeubles_bc` (Les Immeubles Beaulieu et Collin, Rimouski) ni Beaulieu Gestion Immobilière (Rivière-du-Loup, non connectable). | |
| 24 | + | |
| 25 | +## Notes | |
| 26 | +- robots.txt Yoast ouvert (Disallow vide), sitemap public ; politesse 0,6 s. | |
| 27 | +- Volet « Commercial » de l'archive exclu par le filtre `?category=residentiel`. | |
added
reports/connectors/gestion_jdm.md
+28 −0
@@ -0,0 +1,28 @@ | ||
| 1 | +# gestion_jdm — JDM Gestion immobilière (Rivière-du-Loup / Témiscouata) | |
| 2 | +- site: https://gestionjdm.ca/logement/ | |
| 3 | +- méthode: html (liste + 1 fiche par immeuble via self.detail — 3 requêtes au 1er sync) | |
| 4 | +- annonces: 2 (36 rue Plourde, Rivière-du-Loup — 24 unités ; 52 rue de l'Anse, Témiscouata-sur-le-Lac — 15 unités 5½+) | |
| 5 | +- couverture: titre/adresse 100% (adresse complète avec code postal en extrait), ville 100% (parsée de « , <Ville> (QC) »), disponibilité 100% (badge d'étiquette « Disponible »), description 100%, caractéristiques 100% (quand publiées), photos 100% ; type 50% (rempli seulement quand UN seul type est cité — girs/immeubles_guillot) ; prix 0% (jamais publié) | |
| 6 | +- fixture: ok · test: ok (2 verts) | |
| 7 | + | |
| 8 | +## Champs extraits | |
| 9 | +- Liste `/logement/` : cartes du widget **UAEL Posts** (`.uael-post-wrapper.logement`) — lien fiche, extrait = adresse complète, badge `.uael-post__terms` = étiquette WordPress (« Disponible », lien `/tag/disponible/`), vignette. | |
| 10 | +- Fiche : sections `h4.elementor-heading-title` — « Description » (widget text-editor suivant), « Caractéristiques » (icon-list → amenities), « À proximité » (icon-list, ajoutée en fin de description) ; galerie `wp-content/uploads` hors logos, plafond 10. | |
| 11 | +- `self.detail` (cache BD) : fiche re-visitée seulement si l'adresse ou le badge change. | |
| 12 | +- Granularité **immeuble** assumée (« 24 unités de logements sur 3 étages ») : prix/unités jamais publiés → `price=None`, rien d'inventé. | |
| 13 | +- Cartes étiquetées « loué / indisponible / non disponible » (si le site en publie un jour) écartées ; badge brut conservé dans `availability`. | |
| 14 | +- `external_id` = slug de fiche (`logement-1`, `logement-2`). | |
| 15 | + | |
| 16 | +## Champs indisponibles à la source | |
| 17 | +- Prix, superficie, date de disponibilité, animaux : jamais publiés. | |
| 18 | +- 52 rue de l'Anse : pas de section « Caractéristiques » → amenities vide (rien d'inventé). | |
| 19 | + | |
| 20 | +## Périmètre | |
| 21 | +- Pages `/chambre/` (maisons de chambres) et `/commercial/` **exclues** (jamais visitées) — à réévaluer si Lou-Ka ouvre une catégorie « chambres ». | |
| 22 | + | |
| 23 | +## Fragilités | |
| 24 | +- Slugs de fiche générés par WordPress (`logement-1`) : peu descriptifs mais stables tant que les pages ne sont pas recréées. | |
| 25 | +- Petit parc (2 immeubles) ; le badge d'étiquette est le seul signal de disponibilité. | |
| 26 | + | |
| 27 | +## Notes | |
| 28 | +- robots.txt ouvert (Disallow vide), sitemap Yoast public ; politesse 0,6 s. | |
added
reports/connectors/immeubles_bois.md
+23 −0
@@ -0,0 +1,23 @@ | ||
| 1 | +# immeubles_bois — Les Immeubles Bois (Rimouski) | |
| 2 | +- site: https://immeublesbois.ca/residentiel/ | |
| 3 | +- méthode: html (1 requête par sync — les fiches ne sont pas visitées) | |
| 4 | +- annonces: 23 immeubles (catalogue : 320+ logements, 26 immeubles revendiqués ; 1½–5½) | |
| 5 | +- couverture: titre/adresse 100% (adresse civique en titre), photo 100% (vue aérienne par immeuble), ville 100% (Rimouski) ; type 0%, prix 0%, disponibilité 0% (jamais publiés) | |
| 6 | +- fixture: ok · test: ok (2 verts) | |
| 7 | + | |
| 8 | +## Champs extraits | |
| 9 | +- Page `/residentiel/` : un `loop-item` Elementor `.category-residentiel` par immeuble — adresse (`h6 a`), photo aérienne (featured image), lien fiche. Le volet `/commercial/` n'est jamais sélectionné (classe de catégorie différente). | |
| 10 | +- Granularité **immeuble** (précédent girs / immeubles_guillot / gestion_jdm) : annonces « catalogue » avec adresse + photo, location par téléphone (« Demander une visite »). | |
| 11 | +- Fiches d'immeuble **non visitées** : vérifié (157 rue Hudon) — elles ne contiennent que le titre, le bouton « Demander une visite » et des vignettes d'AUTRES immeubles ; rien à extraire de plus, économie de 23 requêtes. | |
| 12 | +- `external_id` = slug de la fiche (`157-159-rue-hudon`) — stable. | |
| 13 | + | |
| 14 | +## Champs indisponibles à la source | |
| 15 | +- Prix, typologies par immeuble, disponibilités, description : jamais publiés (site vitrine) → `price=None`, `availability=""`, rien d'inventé. | |
| 16 | +- Le texte d'intro de la page (« Certains appartements sont loués chauffés et éclairés, d'autres non ») est générique, non attribuable à un immeuble — non repris. | |
| 17 | + | |
| 18 | +## Fragilités | |
| 19 | +- Parc réel de 320+ logements mais donnée limitée à l'immeuble : si le site publie un jour des disponibilités, enrichir le connecteur. | |
| 20 | +- Nom parfois orthographié « Immeubles Blois » dans le pied de page (coquille du site). | |
| 21 | + | |
| 22 | +## Notes | |
| 23 | +- robots.txt : `Allow: /` pour l'agent générique ; blocage ciblé de bots d'entraînement IA (GPTBot, ClaudeBot, CCBot…) qui ne s'applique pas à LouKaBot (UA navigateur + LouKaBot) ; `Content-Signal: search=yes, use=reference` — l'usage agrégateur/référence avec lien retour est cohérent (précédent place_florimay). Politesse 0,6 s. | |
added
reports/connectors/louer_saguenay.md
+23 −0
@@ -0,0 +1,23 @@ | ||
| 1 | +# louer_saguenay — Louer Saguenay (Jonquière) | |
| 2 | +- site: https://louersaguenay.com/location-loft-appartement-jonquiere/ | |
| 3 | +- méthode: html (liste + 1 fiche par annonce via self.detail — 6 requêtes au 1er sync, ~1 ensuite grâce au cache) | |
| 4 | +- annonces: 5 (2 appartements 4½, 2 lofts, 1 loft style 2½ — rue Saint-Dominique, centre-ville de Jonquière) | |
| 5 | +- couverture: titre 100%, adresse 100% (déduite du titre « à louer <adresse> »), type 100% (4½/2½/Loft), prix 100% (« Prix mensuel : » de la fiche, le plus bas quand meublé/non meublé), disponibilité 100% (extrait de liste ou « Disponible : » de la fiche), description 100%, photos 100% | |
| 6 | +- fixture: ok · test: ok (2 verts) | |
| 7 | + | |
| 8 | +## Champs extraits | |
| 9 | +- Liste : boucle Elementor — items `.e-loop-item.category-location-loft-appartement-jonquiere` (titre `h2 a`, extrait `theme-post-excerpt` = disponibilité fraîche ex. « DISPONIBLE AU 1-6-2026 », vignette). La section « NOS ESPACES COMMERCIALES » (articles `ee-post`, catégorie `location-espaces-commercials-saguenay`) n'est jamais sélectionnée. | |
| 10 | +- Fiche : headings Elementor — « Prix mensuel : » suivi du heading tarifs (« 1395.00$ (Non meublé) 1495.00$ (Meublé) » → `price` = plus bas, `price_label` = texte complet) ; « Disponible : » suivi du heading date (recollés) ; paragraphes de la section « Informations » ; galerie `wp-content/uploads` (variantes redimensionnées `-NxN` exclues), plafond 10 images. | |
| 11 | +- `self.detail` (cache BD) : la fiche n'est re-visitée que si le titre ou l'extrait change. | |
| 12 | +- `external_id` = slug de la fiche. `city=Saguenay`, `sector=Jonquière` (convention SLSJ). | |
| 13 | + | |
| 14 | +## Champs indisponibles à la source | |
| 15 | +- Superficie, animaux : jamais publiés. | |
| 16 | +- `furnished` laissé à None quand les deux tarifs (meublé / non meublé) coexistent — les deux formules sont offertes. | |
| 17 | + | |
| 18 | +## Fragilités | |
| 19 | +- Les dates « Disponible : » des fiches sont parfois **périmées** (1er juillet 2022/2023 encore affichés) ; l'extrait de la liste est plus frais quand présent (prioritaire dans le connecteur). Données brutes conservées telles quelles. | |
| 20 | +- Petit parc (~5 unités résidentielles) essentiellement au 2345/2397 rue Saint-Dominique ; le site est surtout commercial (bureaux exclus). | |
| 21 | + | |
| 22 | +## Notes | |
| 23 | +- robots.txt : Disallow `/imunify-bot-check` seulement + bloc Yoast ouvert, sitemap public ; politesse 0,6 s. | |
added
reports/connectors/luxuor.md
+32 −0
@@ -0,0 +1,32 @@ | ||
| 1 | +# luxuor — Les Immeubles Luxuor (Saguenay) | |
| 2 | +- site: https://www.lesimmeublesluxuor.com | |
| 3 | +- méthode: html (4 pages projet fixes = 4 requêtes par sync) | |
| 4 | +- annonces: 4 (catalogue à granularité PROJET : 42 Logements et 16 Logements rue des Golfeurs à Chicoutimi, Quadrex, résidence étudiante La Fabrique à Jonquière — 12 unités 4½, location par chambre ou unité complète) | |
| 5 | +- couverture: titre 100%, description 100%, commodités 100% (pages Golfeurs/Quadrex), adresse 75% (rue des Golfeurs ; 3821 rue de la Fabrique complète ; Quadrex sans adresse publiée), type 25% (La Fabrique seule cite UN type unique « 4 ½ ») ; prix 0%, disponibilité 0% (jamais publiés — visites par formulaire) | |
| 6 | +- fixture: ok · test: ok (2 verts) | |
| 7 | + | |
| 8 | +## Champs extraits | |
| 9 | +- Site **Wix rendu serveur** (pages lourdes ~1,2-3 Mo mais texte dans le HTML). Extraction limitée aux blocs `div[data-testid="richTextElement"]` du `<main>` : les menus, légendes de galeries (« 5 1/2 - 403 Chambre / Vue 1 ») et pieds de page sont hors de ces blocs (vérifié). | |
| 10 | +- Une commodité Wix par `<p>` (« Ascenseur », « Gym », « Terrasse au toit »…) : lignes courtes (3-44 car., sans ponctuation finale) → amenities ; lignes ≥ 45 car. → description ; CTA/titres écartés par motif (`PHOTOS & PLANS`, « Réservez… »). | |
| 11 | +- La page La Fabrique (sections Chambre 1/Cuisine/Services inclus en prose) est traitée en description seule (amenities désactivées pour éviter les intitulés de section). | |
| 12 | +- `_full_size` : URLs `wixstatic` redimensionnées coupées au `/v1/` (précédent habitations_jeanne) ; plafond 10 images, SVG exclus. | |
| 13 | +- `external_id` = slug de page (`42logements`, `16logements`, `quadrex`, `lafabrique`). | |
| 14 | +- `city=Saguenay` : tous les projets du site sont dans la ville de Saguenay (Golfeurs/Quadrex : arrondissement Chicoutimi ; La Fabrique : Jonquière) ; secteur rempli quand la page l'affiche. | |
| 15 | +- Garde-fou : page sans description ni commodités → non émise (refonte détectable au test). | |
| 16 | + | |
| 17 | +## Champs indisponibles à la source | |
| 18 | +- Prix, disponibilités, fiches d'unités : jamais publiés (« Réservez votre visite » → formulaire) → `price=None`, `availability=""`. | |
| 19 | +- Types multiples « 3 1/2, 4 1/2 et 5 1/2 » (Golfeurs) → `unit_type` vide, la description fait foi (convention girs). | |
| 20 | +- Quadrex : aucune adresse publiée sur la page. | |
| 21 | + | |
| 22 | +## Positionnement éditorial | |
| 23 | +- La Fabrique = **résidence étudiante** (logement locatif étudiant, comme UTILE/Werkliv déjà dans Lou-Ka), pas une RPA. | |
| 24 | +- Le menu « LOGEMENTS DISPONIBLES » du site est un simple dossier de navigation vers ces pages projet — aucune page de disponibilités réelle. | |
| 25 | + | |
| 26 | +## Fragilités | |
| 27 | +- Heuristique lignes courtes/longues : un remaniement éditorial Wix peut déplacer des libellés (sans inventer de données, au pire du bruit dans amenities). | |
| 28 | +- Pages Wix très lourdes (1,2-3 Mo) : 4 requêtes par sync, politesse 0,6 s. | |
| 29 | + | |
| 30 | +## Notes | |
| 31 | +- robots.txt Wix standard ouvert, sitemap XML public. | |
| 32 | +- L'éco-quartier domaineluxuor.com (site distinct) n'est pas couvert. | |
added
reports/connectors/quartier_plus.md
+28 −0
@@ -0,0 +1,28 @@ | ||
| 1 | +# quartier_plus — Le Quartier Plus (Thetford Mines) | |
| 2 | +- site: https://www.lequartierplus.com/plans-et-visites-virtuelles/ | |
| 3 | +- méthode: html (1 requête par sync) | |
| 4 | +- annonces: 59 (unités « disponible — Construction 2026 » de la phase 3, 140 rue Kingsville) | |
| 5 | +- couverture: titre 100%, type 100% (badge 3½/4½/4½+/5½ ; le « + » est gardé dans le titre), superficie 100%, balcon 100% (amenities + details), plan (image) 100%, disponibilité 100% (texte brut « disponible Construction 2026 ») ; prix 0% (jamais publié par unité) | |
| 6 | +- fixture: ok · test: ok (2 verts) | |
| 7 | + | |
| 8 | +## Champs extraits | |
| 9 | +- Sélecteur d'étages WordPress (thème `ubeo-theme-v5-child`) rendu **serveur** : tous les blocs `div.unit#unit_<n>` sont dans le HTML brut (SVG des étages + fiches d'unités). Aucune requête AJAX nécessaire. | |
| 10 | +- Par unité : numéro (`Unité 501`), statut (`span.has-wpdc-medium-font-size`, ex. « disponible / Construction 2026 »), badge de type (`3½` `4½` `4½+` `5½`), lettre de modèle (« TYPE L »), superficie du logement et du balcon (`p²`), plan PNG (`p.unit_plan img`) + PDF. | |
| 11 | +- Seules les unités dont le statut contient « disponible » (et pas « indisponible/loué/réservé ») sont émises. | |
| 12 | +- `external_id` = numéro d'unité (`unit_501` → `501`) — stable. | |
| 13 | +- Adresse : la page publie « Phase 1 et 2 : 180, rue Kingsville / Phase 3 : 140, rue Kingsville » ; l'adresse phase 3 n'est attachée qu'aux unités marquées « Construction 2026 » (les 59 actuelles). Les unités des phases 1-2 réapparaîtraient sans adresse (ville seulement), rien d'inventé. | |
| 14 | + | |
| 15 | +## Champs indisponibles à la source | |
| 16 | +- Prix par unité : jamais publié — seul un « à partir de 1195 $ » global figure en prose → `price=None` sur chaque unité (pas de prix inventé). | |
| 17 | +- Date précise de livraison : « Construction 2026 » sans mois (le normaliseur peut classer « disponible » comme immédiat ; le texte brut complet est conservé dans `availability`). | |
| 18 | + | |
| 19 | +## Positionnement éditorial | |
| 20 | +- Complexe **55+ autonome** (« autonomie, liberté, sécurité », cohabitation bigénérationnelle) — PAS une RPA : aucun service de repas/soins/surveillance médicale sur le site. Retenu au titre du locatif standard ; à re-trancher si la politique éditoriale Lou-Ka exclut le 55+. | |
| 21 | +- Phase 3 = prélocation (livraison été 2026), flux réel d'annonces : les statuts passeront à loué/indisponible au fil de la location. | |
| 22 | + | |
| 23 | +## Fragilités | |
| 24 | +- Sélecteur custom du thème : un changement de thème casserait les sélecteurs (`div.unit`, `has-wpdc-*`). | |
| 25 | +- Le compte « 12 unités » par étage est décoratif ; la vérité est le nombre de blocs `div.unit` (59). | |
| 26 | + | |
| 27 | +## Notes | |
| 28 | +- robots.txt Yoast ouvert (`Disallow:` vide), sitemap public ; politesse 0,6 s, 1 requête par sync. | |
added
tests/fixtures/alias_appartements/expected.json
+19 −0
@@ -0,0 +1,19 @@ | ||
| 1 | +{ | |
| 2 | + "count": 1, | |
| 3 | + "listings": [ | |
| 4 | + { | |
| 5 | + "uid": "alias_appartements:rue-des-mouettes", | |
| 6 | + "url": "https://www.aliasappartements.com/", | |
| 7 | + "title": "Alias Appartements — Rue des Mouettes, Jonquière", | |
| 8 | + "address": "Rue des Mouettes, Jonquière", | |
| 9 | + "sector": "Jonquière", | |
| 10 | + "city": "Saguenay", | |
| 11 | + "unit_type": "4½", | |
| 12 | + "price": null, | |
| 13 | + "availability": "", | |
| 14 | + "area_sqft": null, | |
| 15 | + "n_images": 10, | |
| 16 | + "n_amenities": 13 | |
| 17 | + } | |
| 18 | + ] | |
| 19 | +} | |
| \ No newline at end of file | ||
added
tests/fixtures/alias_appartements/f941ea0484876fa15a2c.html
+1576 −0
Large diff (1,577 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/alias_appartements/index.json
+9 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/beaulieu_groupe/2c6cc8d8cf0e0b0f7dfd.html
+647 −0
Large diff (647 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/beaulieu_groupe/a0f56c2039ed22573677.html
+640 −0
Large diff (641 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/beaulieu_groupe/expected.json
+61 −0
Large diff (62 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/beaulieu_groupe/index.json
+16 −0
Large diff (17 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/gestion_jdm/0b37051520469d5a5279.html
+643 −0
Large diff (643 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/gestion_jdm/5481917e51fe8ac59b4c.html
+668 −0
Large diff (668 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/gestion_jdm/aa32096c58c013119455.html
+618 −0
Large diff (618 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/gestion_jdm/expected.json
+33 −0
Large diff (34 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/gestion_jdm/index.json
+23 −0
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/immeubles_bois/0b72cbae0ce245b01a0a.html
+913 −0
Large diff (914 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/immeubles_bois/expected.json
+327 −0
Large diff (328 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/immeubles_bois/index.json
+9 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/louer_saguenay/02eb09bb6648cad035a8.html
+887 −0
Large diff (887 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/louer_saguenay/4a755938da6786468abf.html
+878 −0
Large diff (878 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/louer_saguenay/4b130c92483a220dc17d.html
+672 −0
Large diff (672 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/louer_saguenay/55ec371485f9bce53948.html
+867 −0
Large diff (867 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/louer_saguenay/82f877e517bb3d6941dc.html
+887 −0
Large diff (887 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/louer_saguenay/c1ed9dda6ed1684655e8.html
+951 −0
Large diff (951 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/louer_saguenay/expected.json
+75 −0
Large diff (76 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/louer_saguenay/index.json
+44 −0
Large diff (45 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/luxuor/4783d2d75a2eb428f54f.html
+2293 −0
Large diff (2,293 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/luxuor/5615f77a816e847cd387.html
+2279 −0
Large diff (2,279 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/luxuor/70d1c5461bc910bc202c.html
+2329 −0
Large diff (2,329 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/luxuor/c10816031527aa51bcf1.html
+514 −0
Large diff (514 lines) is not loaded by default to keep the page fast.Load diff
Diff truncated — file too large.