SPB Git

spb/lou-ka

Public

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

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

41 changed files +20,001 −0

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

Menu

Theme