SPB Git

spb/lou-ka

Public

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

Expansion P3 — Mauricie/CdQ/Lanaudière (6 connecteurs +53 annonces, 8 non-connectables)

moderno · acceslogis_gb · ferrovia (23, prix non publiés) · habitations_sf ·
lambert · fournelle. Non-connectables documentés (parcs pleins, vitrines
sans unités, renvois Facebook).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 9, 2026) parent 61be1e3 Browse files

27 changed files +20,001 −0

added louka/connectors/acceslogis_gb.py +159 −0
@@ -0,0 +1,159 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/acceslogis_gb.py : connecteur Accès Logis GB (acceslogisgb.com)
5 +# Gestionnaire de Lanaudière/Mauricie (Joliette, Ste-Élisabeth,
6 +# St-Ambroise-de-Kildare, Shawinigan…). Site builder mono-page : les cartes
7 +# « LOGEMENTS DISPONIBLES » vivent dans des grilles .columnswithgap-02
8 +# (titre <p.font-026><b>, photo, description <p.font-014> qui se termine par
9 +# « Disponible … » + « 1150$ PAR MOIS »). Aucune page détail ni URL par
10 +# annonce : external_id = slug du titre + repère d'étage tiré de la
11 +# description (les titres se répètent d'un étage à l'autre). 1 requête/sync.
12 +# -----------------------------------------------------------------------------
13 +from __future__ import annotations
14 +
15 +import hashlib
16 +import re
17 +import unicodedata
18 +
19 +from bs4 import BeautifulSoup
20 +
21 +from ..schema import Listing, normalize_unit_type, parse_price
22 +from .base import BaseConnector
23 +
24 +BASE = "https://acceslogisgb.com"
25 +LIST_URL = f"{BASE}/"
26 +
27 +# villes desservies (clé sans accents, en minuscules -> nom canonique)
28 +_CITIES = [
29 + ("ste-elisabeth", "Sainte-Élisabeth"),
30 + ("sainte-elisabeth", "Sainte-Élisabeth"),
31 + ("sainte-elizabeth", "Sainte-Élisabeth"),
32 + ("st-ambroise", "Saint-Ambroise-de-Kildare"),
33 + ("saint-ambroise", "Saint-Ambroise-de-Kildare"),
34 + ("shawinigan", "Shawinigan"),
35 + ("joliette", "Joliette"),
36 + ("crabtree", "Crabtree"),
37 + ("berthier", "Berthierville"),
38 + ("st-thomas", "Saint-Thomas"),
39 + ("saint-thomas", "Saint-Thomas"),
40 + ("st-come", "Saint-Côme"),
41 + ("saint-come", "Saint-Côme"),
42 +]
43 +
44 +# repère d'étage dans la description (« en demi sous-sol », « au 2e étage »…)
45 +_FLOOR_RE = re.compile(
46 + r"(demi[- ]sous[- ]sol|sous[- ]sol|rez[- ]de[- ]chauss[ée]e|\d+\s*(?:er|e|ème|ieme)\s*étage)",
47 + re.I)
48 +
49 +
50 +def _strip_accents(s: str) -> str:
51 + return "".join(c for c in unicodedata.normalize("NFD", s)
52 + if unicodedata.category(c) != "Mn")
53 +
54 +
55 +def _slug(s: str) -> str:
56 + s = _strip_accents(s.lower())
57 + s = re.sub(r"[^a-z0-9]+", "-", s)
58 + return s.strip("-")
59 +
60 +
61 +class AccesLogisGBConnector(BaseConnector):
62 + source_id = "acceslogis_gb"
63 + request_delay = 0.7
64 +
65 + def fetch(self) -> list[Listing]:
66 + resp = self.get(LIST_URL)
67 + resp.encoding = "utf-8" # le serveur ne déclare pas le charset
68 + html = resp.text
69 + soup = BeautifulSoup(html, "html.parser")
70 + listings: dict[str, Listing] = {}
71 + for grid in soup.select("div.columnswithgap-02"):
72 + for col in grid.find_all("div", recursive=False):
73 + try:
74 + self._parse_card(col, listings)
75 + except Exception:
76 + continue
77 + return list(listings.values())
78 +
79 + def _parse_card(self, col, listings: dict[str, Listing]) -> None:
80 + title_el = col.select_one("p.font-026 b")
81 + if not title_el:
82 + return
83 + title = re.sub(r"\s+", " ", title_el.get_text(" ", strip=True))
84 + # hors périmètre logement : mini-entrepôts, locaux
85 + if re.search(r"entrep[oô]t|commercial|local\b", title, re.I):
86 + return
87 +
88 + desc_el = col.select_one("p.font-014")
89 + availability, price_label = "", ""
90 + description = ""
91 + if desc_el:
92 + # les mentions « Disponible … » et « 1150$ » sont des <span> en fin
93 + # de paragraphe : on les extrait puis on garde le reste en description
94 + for span in desc_el.find_all("span"):
95 + t = re.sub(r"\s+", " ", span.get_text(" ", strip=True))
96 + if re.match(r"(?i)disponible|libre", t):
97 + availability = t
98 + elif re.search(r"\d\s*\$", t):
99 + price_label = t
100 + span.extract()
101 + description = re.sub(r"\s+", " ", desc_el.get_text(" ", strip=True))
102 + description = re.sub(r"\bPAR MOIS\b\s*$", "", description).strip()
103 +
104 + # ville : depuis le titre, sinon la description
105 + low = _strip_accents(f"{title} {description}".lower())
106 + city = ""
107 + for key, name in _CITIES:
108 + if key in low:
109 + city = name
110 + break
111 +
112 + # type d'unité : titre (« 3 1/2, … ») sinon description
113 + unit_type = normalize_unit_type(title)
114 + if not re.fullmatch(r"\d½|6½\+|Studio|Loft|Chambre|Maison|Condo",
115 + unit_type or ""):
116 + unit_type = normalize_unit_type(description)
117 + if not re.fullmatch(r"\d½|6½\+|Studio|Loft|Chambre|Maison|Condo",
118 + unit_type or ""):
119 + unit_type = ""
120 +
121 + # adresse civique si mentionnée (« situé au 2510 Rang du Ruisseau à … »)
122 + address = ""
123 + m = re.search(r"situ[ée]e?\s+au\s+([\d][^.,]*?)\s+à\s", description)
124 + if m:
125 + address = m.group(1).strip()
126 + elif re.match(r"\d+\s+\w", title) and not normalize_unit_type(title).endswith("½"):
127 + address = title.split(",")[0].strip() # le titre est une adresse civique
128 +
129 + # external_id stable : slug du titre + repère d'étage (les titres se
130 + # répètent entre étages d'un même immeuble)
131 + ext = _slug(title)
132 + m_fl = _FLOOR_RE.search(description)
133 + if m_fl:
134 + ext += "-" + _slug(m_fl.group(1))
135 + if ext in listings: # ultime repli : hash du texte
136 + ext += "-" + hashlib.sha1(description.encode("utf-8")).hexdigest()[:6]
137 +
138 + images: list[str] = []
139 + img = col.select_one("img[src]")
140 + if img:
141 + src = img["src"]
142 + if not src.startswith("http"):
143 + src = f"{BASE}/{src.lstrip('/')}"
144 + images.append(src)
145 +
146 + listings[ext] = Listing(
147 + source=self.source_id,
148 + external_id=ext,
149 + url=f"{LIST_URL}#logements",
150 + title=title,
151 + address=address,
152 + city=city,
153 + unit_type=unit_type,
154 + price=parse_price(price_label),
155 + price_label=price_label,
156 + availability=availability,
157 + description=description[:900],
158 + images=images,
159 + )
added louka/connectors/ferrovia.py +126 −0
@@ -0,0 +1,126 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/ferrovia.py : connecteur Ferrovia (ferroviamirabel.com)
5 +# Projet de condos locatifs neufs à Mirabel (secteur Saint-Janvier), 4 phases.
6 +# WordPress + wpDataTables rendues côté serveur : chaque page « Disponibilités »
7 +# contient un <table.wpDataTable> (UNITÉ, MODÈLE, ÉTAGE, PIÈCES, SUPERFICIE,
8 +# SALLE D'EAU SUPP., DISPONIBILITÉ, PRIX, STATUT, PLAN). On ne retient que les
9 +# lignes STATUT = « Disponible » ; external_id = phase + numéro d'unité.
10 +# Le site précise que les logements sont non-fumeurs et sans animaux.
11 +# Prix : colonne présente mais vide à ce jour — jamais inventé.
12 +# -----------------------------------------------------------------------------
13 +from __future__ import annotations
14 +
15 +import re
16 +
17 +from bs4 import BeautifulSoup
18 +
19 +from ..schema import Listing, normalize_unit_type, parse_price
20 +from .base import BaseConnector
21 +
22 +BASE = "https://www.ferroviamirabel.com"
23 +PHASES = [
24 + ("1", f"{BASE}/disponibilites-prix-plans-phase1/"),
25 + ("3", f"{BASE}/disponibilites-phase-3/"),
26 + ("4", f"{BASE}/disponibilites-phase-4/"),
27 +]
28 +
29 +
30 +class FerroviaConnector(BaseConnector):
31 + source_id = "ferrovia"
32 + request_delay = 0.7
33 +
34 + def fetch(self) -> list[Listing]:
35 + listings: dict[str, Listing] = {}
36 + for phase, url in PHASES:
37 + try:
38 + html = self.get(url).text
39 + except Exception:
40 + continue
41 + self._parse_phase(phase, url, html, listings)
42 + return list(listings.values())
43 +
44 + def _parse_phase(self, phase: str, url: str, html: str,
45 + listings: dict[str, Listing]) -> None:
46 + soup = BeautifulSoup(html, "html.parser")
47 + table = soup.select_one("table.wpDataTable")
48 + if table is None:
49 + return
50 + heads = [th.get_text(" ", strip=True).lower()
51 + for th in table.select("thead th")]
52 +
53 + def col(row_cells, *keys):
54 + for key in keys:
55 + for i, h in enumerate(heads):
56 + if key in h and i < len(row_cells):
57 + return row_cells[i]
58 + return None
59 +
60 + for tr in table.select("tbody tr"):
61 + cells = tr.find_all("td")
62 + texts = [re.sub(r"\s+", " ", td.get_text(" ", strip=True))
63 + for td in cells]
64 + statut = col(texts, "statut") or ""
65 + if not re.match(r"(?i)disponible", statut):
66 + continue # loué / réservé : on saute
67 + unit = col(texts, "unité", "unite") or ""
68 + if not unit:
69 + continue
70 + pieces = col(texts, "pièces", "pieces") or ""
71 + unit_type = normalize_unit_type(pieces)
72 + if not re.fullmatch(r"\d½|Studio|Loft", unit_type or ""):
73 + continue # ligne non résidentielle
74 + modele = col(texts, "modèle", "modele") or ""
75 + etage = col(texts, "étage", "etage") or ""
76 + superficie = col(texts, "superficie") or ""
77 + demi_sdb = col(texts, "salle d'eau", "salle d’eau") or ""
78 + dispo = col(texts, "disponibilité", "disponibilite") or ""
79 + prix = col(texts, "prix") or "" # vide à ce jour sur le site
80 +
81 + area = None
82 + m = re.match(r"([\d\s,.]+)$", superficie)
83 + if m:
84 + try:
85 + area = float(m.group(1).replace(" ", "").replace(",", ""))
86 + except ValueError:
87 + area = None
88 +
89 + plan_td = col(cells, "plan")
90 + plan_url = ""
91 + if plan_td is not None:
92 + a = plan_td.find("a", href=True)
93 + if a:
94 + plan_url = a["href"]
95 +
96 + details = {"phase": phase, "smoking": False}
97 + if modele:
98 + details["model"] = modele
99 + if etage:
100 + details["floor"] = etage
101 + if plan_url:
102 + details["plan_pdf"] = plan_url
103 + amenities = []
104 + if re.match(r"(?i)oui", demi_sdb):
105 + amenities.append("Salle d'eau supplémentaire")
106 +
107 + ext = f"phase{phase}-{unit}"
108 + listings[ext] = Listing(
109 + source=self.source_id,
110 + external_id=ext,
111 + url=url,
112 + title=f"Ferrovia phase {phase} — unité {unit} ({pieces})",
113 + sector="Saint-Janvier",
114 + city="Mirabel",
115 + unit_type=unit_type,
116 + price=parse_price(prix),
117 + price_label=prix,
118 + availability=dispo, # « Automne 2026 », « ÉTÉ 2026 »
119 + area_sqft=area,
120 + pets="non", # note du site : animaux non admis
121 + description=("Condos neufs à louer du projet Ferrovia, à Mirabel "
122 + "dans le secteur de Saint-Janvier. Les logements sont "
123 + "non-fumeurs et les animaux ne sont pas admis."),
124 + amenities=amenities,
125 + details=details,
126 + )
added louka/connectors/fournelle.py +113 −0
@@ -0,0 +1,113 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/fournelle.py : connecteur Appartements Fournelle (Groupe Fournelle)
5 +# (groupefournelle.com/appartements-fournelle/ — Bécancour, Centre-du-Québec).
6 +# Page-brochure WordPress : des blocs .item par projet (h2 + paragraphes +
7 +# galerie swiper). Seuls les blocs affichant des lignes de prix par unité
8 +# (« 2 × 5½ au sous-sol – 1300 $ | 2 × 5½ au RDC – 1525 $ … ») produisent des
9 +# annonces : une annonce par ligne typologie/étage, prix et disponibilité
10 +# (« Immeuble neuf disponible à partir du 1er Mai 2026 ») fidèles au texte.
11 +# external_id = slug du projet + typologie + étage. 1 requête par sync.
12 +# -----------------------------------------------------------------------------
13 +from __future__ import annotations
14 +
15 +import re
16 +import unicodedata
17 +
18 +from bs4 import BeautifulSoup
19 +
20 +from ..schema import Listing, normalize_unit_type, parse_price
21 +from .base import BaseConnector
22 +
23 +BASE = "https://www.groupefournelle.com"
24 +LIST_URL = f"{BASE}/appartements-fournelle/"
25 +
26 +# « 2 × 5½ au sous-sol – 1300 $ » (séparateur | ; tirets – ou - ; l'étage peut
27 +# contenir un trait d'union — « sous-sol » — d'où le prix ancré sur un chiffre)
28 +_LINE_RE = re.compile(
29 + r"(\d+)\s*[×x]\s*(\d\s*(?:½|1/2))\s*(?:aux?|en)?\s*([^–|]*?)\s*[–\-]\s*(\d[\d\s]*\$)")
30 +_AVAIL_RE = re.compile(r"disponible\s+à\s+partir\s+d[ue][^.|]*", re.I)
31 +
32 +
33 +def _strip_accents(s: str) -> str:
34 + return "".join(c for c in unicodedata.normalize("NFD", s)
35 + if unicodedata.category(c) != "Mn")
36 +
37 +
38 +def _slug(s: str) -> str:
39 + s = _strip_accents(s.lower()).replace("½", "12")
40 + return re.sub(r"[^a-z0-9]+", "-", s).strip("-")
41 +
42 +
43 +class FournelleConnector(BaseConnector):
44 + source_id = "fournelle"
45 + request_delay = 0.7
46 +
47 + def fetch(self) -> list[Listing]:
48 + html = self.get(LIST_URL).text
49 + soup = BeautifulSoup(html, "html.parser")
50 + listings: dict[str, Listing] = {}
51 + for item in soup.select("div.item"):
52 + content = item.select_one("div.content")
53 + if content is None:
54 + continue
55 + try:
56 + self._parse_block(item, content, listings)
57 + except Exception:
58 + continue
59 + return list(listings.values())
60 +
61 + def _parse_block(self, item, content, listings: dict[str, Listing]) -> None:
62 + h2 = content.find("h2")
63 + block_title = re.sub(r"\s+", " ", h2.get_text(" ", strip=True)) if h2 else ""
64 + link = content.select_one("a[href]")
65 + project = _slug((link["href"] if link else "").strip("/")) or _slug(block_title)
66 +
67 + text = re.sub(r"\s+", " ", content.get_text(" ", strip=True))
68 + lines = _LINE_RE.findall(text)
69 + if not lines:
70 + return # bloc informatif sans prix : pas d'annonce
71 +
72 + m_av = _AVAIL_RE.search(text)
73 + availability = re.sub(r"\s+", " ", m_av.group(0)).strip() if m_av else ""
74 +
75 + # caractéristiques (liste <ul>) + phrases de conditions (sans fumée…)
76 + feats = [re.sub(r"\s+", " ", li.get_text(" ", strip=True))
77 + for li in content.find_all("li")]
78 + conditions = [re.sub(r"\s+", " ", p.get_text(" ", strip=True))
79 + for p in content.find_all("p")
80 + if re.search(r"sans fumée|sans animaux|enquête de crédit|"
81 + r"stationnement inclus|chauffé", p.get_text(), re.I)]
82 + description = "\n".join([block_title] + conditions)[:900]
83 +
84 + # galerie swiper du bloc (photos + plans, pleine taille)
85 + images: list[str] = []
86 + for a in item.select(".swiper-slide a[href]"):
87 + u = a["href"]
88 + if u.startswith("http") and re.search(r"\.(jpe?g|png|webp)$", u, re.I) \
89 + and u not in images:
90 + images.append(u)
91 +
92 + for qty, utype, floor, price in lines:
93 + floor = floor.strip(" .,")
94 + ext = _slug(f"{project}-{utype}-{floor}")
95 + if ext in listings:
96 + continue
97 + label = f"{utype} {('au ' + floor) if floor else ''}".strip()
98 + listings[ext] = Listing(
99 + source=self.source_id,
100 + external_id=ext,
101 + url=LIST_URL,
102 + title=f"{label} — {block_title}",
103 + city="Bécancour", # page « Appartements à louer à Bécancour »
104 + unit_type=normalize_unit_type(utype),
105 + price=parse_price(price),
106 + price_label=price.strip(),
107 + availability=availability,
108 + description=description,
109 + amenities=feats[:20],
110 + details={"project": project, "floor": floor,
111 + "units_on_line": qty},
112 + images=images[:15],
113 + )
added louka/connectors/habitations_sf.py +231 −0
@@ -0,0 +1,231 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/habitations_sf.py : connecteur Les Habitations SF (S&F Gestion)
5 +# (leshabitationssf.com — Saint-Charles-Borromée/Joliette, Lanaudière).
6 +# Site Wix : les annonces sont des pages dynamiques /copy-of-location/<slug>
7 +# (collection Wix), rendues côté serveur — le HTML contient le titre, le
8 +# statut (« Disponible »/« Loué »), chambres/sdb/pi², le prix « … $ / mois »,
9 +# la description riche (ligne « Adresse: … ») et la galerie wixstatic.
10 +# Index = liens du répéteur de la page d'accueil ; external_id = slug ;
11 +# fiches détail via cache BD (clé = hash du répéteur : tout changement
12 +# d'accueil déclenche la relecture des fiches).
13 +# -----------------------------------------------------------------------------
14 +from __future__ import annotations
15 +
16 +import hashlib
17 +import re
18 +import unicodedata
19 +from urllib.parse import unquote
20 +
21 +from bs4 import BeautifulSoup
22 +
23 +from ..schema import Listing, normalize_unit_type, parse_price
24 +from .base import BaseConnector
25 +
26 +BASE = "https://www.leshabitationssf.com"
27 +LIST_URL = f"{BASE}/"
28 +
29 +# id du média wixstatic (« 5ae170_…~mv2.png ») pour dédupliquer la galerie
30 +_MEDIA_ID = re.compile(r"/media/([^/]+~mv2\.\w+)")
31 +
32 +_CITIES = [
33 + ("saint-charles", "Saint-Charles-Borromée"),
34 + ("st-charles", "Saint-Charles-Borromée"),
35 + ("notre-dame-des-prairies", "Notre-Dame-des-Prairies"),
36 + ("saint-felix", "Saint-Félix-de-Valois"),
37 + ("st-felix", "Saint-Félix-de-Valois"),
38 + ("joliette", "Joliette"),
39 + ("piedmont", "Piedmont"),
40 + ("saint-paul", "Saint-Paul"),
41 +]
42 +
43 +_STATUTS_PARTIS = re.compile(r"(?i)^(lou[ée]e?|non disponible|r[ée]serv[ée]e?)$")
44 +
45 +
46 +def _strip_accents(s: str) -> str:
47 + return "".join(c for c in unicodedata.normalize("NFD", s)
48 + if unicodedata.category(c) != "Mn")
49 +
50 +
51 +def _slug(s: str) -> str:
52 + s = _strip_accents(unquote(s).lower())
53 + return re.sub(r"[^a-z0-9]+", "-", s).strip("-")
54 +
55 +
56 +def _find_city(txt: str) -> str:
57 + low = re.sub(r"[\s_]+", "-", _strip_accents(txt.lower()))
58 + for key, name in _CITIES:
59 + if key in low:
60 + return name
61 + return ""
62 +
63 +
64 +class HabitationsSFConnector(BaseConnector):
65 + source_id = "habitations_sf"
66 + request_delay = 0.8
67 + max_details = 25 # garde-fou fiches détail (vraies requêtes par sync)
68 +
69 + def fetch(self) -> list[Listing]:
70 + html = self.get(LIST_URL).text
71 + soup = BeautifulSoup(html, "html.parser")
72 + # liens du répéteur d'accueil (ordre conservé, dédupliqués)
73 + hrefs: list[str] = []
74 + for a in soup.select('a[href*="copy-of-location/"]'):
75 + href = a["href"]
76 + if href.startswith("/"):
77 + href = BASE + href
78 + if href not in hrefs:
79 + hrefs.append(href)
80 + # clé de cache : hash des liens + du texte du répéteur (prix affichés) —
81 + # tout changement sur l'accueil déclenche la relecture des fiches
82 + idx_txt = " ".join(hrefs) + re.sub(
83 + r"\s+", " ",
84 + " ".join(el.get_text(" ", strip=True)
85 + for el in soup.select('[data-testid="richTextElement"]')))
86 + index_key = hashlib.sha1(idx_txt.encode("utf-8")).hexdigest()
87 +
88 + self._fetched = 0
89 + listings: list[Listing] = []
90 + for href in hrefs:
91 + ext = _slug(href.rsplit("/", 1)[-1])
92 + if not ext:
93 + continue
94 + try:
95 + payload = self.detail(ext, index_key,
96 + lambda u=href: self._fetch_detail(u))
97 + except Exception:
98 + continue
99 + lst = self._build(ext, href, payload)
100 + if lst is not None:
101 + listings.append(lst)
102 + return listings
103 +
104 + # -- fiche détail (page dynamique Wix) ----------------------------------------
105 + def _fetch_detail(self, url: str) -> dict:
106 + if self._fetched >= self.max_details:
107 + raise RuntimeError("budget de fiches détail atteint")
108 + self._fetched += 1
109 + html = self.get(url).text
110 + soup = BeautifulSoup(html, "html.parser")
111 + out: dict = {}
112 +
113 + title_el = soup.find("title")
114 + out["title"] = re.sub(r"\s+", " ", title_el.get_text(strip=True)) if title_el else ""
115 +
116 + rts = [re.sub(r"[​]", "",
117 + re.sub(r"\s+", " ", el.get_text(" ", strip=True))).strip()
118 + for el in soup.select('[data-testid="richTextElement"]')]
119 + rts = [t for t in rts if t]
120 +
121 + # statut de l'unité (« Disponible » / « Loué »)
122 + for t in rts:
123 + if re.match(r"(?i)^(disponible|lou[ée]e?|non disponible|r[ée]serv[ée]e?)$", t):
124 + out["status"] = t
125 + break
126 +
127 + # champs structurés : valeur qui précède « Chambre(s) », « Salle(s) de
128 + # bain », « Pieds² » ; prix = « <n> $ / mois » dans la séquence
129 + def before(label_re: str) -> str:
130 + for i, t in enumerate(rts):
131 + if re.match(label_re, t, re.I) and i > 0:
132 + return rts[i - 1]
133 + return ""
134 + out["bedrooms"] = before(r"^chambre")
135 + out["bathrooms"] = before(r"^salle\(s\) de bain|^salles? de bain")
136 + out["sqft"] = before(r"^pieds")
137 + joined = " ".join(rts)
138 + m = re.search(r"(\d[\d\s,.]*)\s*\$\s*/\s*mois", joined)
139 + if m:
140 + out["price_label"] = f"{m.group(1).strip()} $ / mois"
141 + else: # certaines fiches omettent le « $ »
142 + m = re.search(r"(\d{3,4})\s*/\s*mois", joined)
143 + if m:
144 + out["price_label"] = f"{m.group(1)} / mois"
145 +
146 + # description riche = plus long bloc de texte (contient « Adresse: … »)
147 + long_txts = [el.get_text("\n", strip=True)
148 + for el in soup.select('[data-testid="richTextElement"]')
149 + if len(el.get_text(strip=True)) > 200]
150 + if long_txts:
151 + out["description"] = max(long_txts, key=len)[:1500]
152 +
153 + # galerie : images wixstatic grand format, dédupliquées par id de média
154 + images, seen = [], set()
155 + for im in soup.find_all("img"):
156 + src = im.get("src") or ""
157 + if "wixstatic.com/media/" not in src:
158 + continue
159 + m_id = _MEDIA_ID.search(src)
160 + if not m_id or m_id.group(1) in seen:
161 + continue
162 + if not re.search(r"w_(9\d\d|\d{4,})", src):
163 + continue # vignettes/логos : trop petits
164 + seen.add(m_id.group(1))
165 + images.append(src)
166 + out["images"] = images[:20]
167 + return out
168 +
169 + # -- assemblage ---------------------------------------------------------------
170 + def _build(self, ext: str, url: str, d: dict) -> Listing | None:
171 + if not d:
172 + return None
173 + if _STATUTS_PARTIS.match(d.get("status", "")):
174 + return None # unité louée/réservée
175 + title = d.get("title", "")
176 + desc = d.get("description", "")
177 +
178 + # adresse : ligne « Adresse: rue de la Visitation, Saint-Charles-Borromée »
179 + address, city = "", ""
180 + m = re.search(r"Adresse\s*:\s*([^\n]+)", desc, re.I)
181 + if m:
182 + parts = [p.strip() for p in m.group(1).split(",") if p.strip()]
183 + city = _find_city(parts[-1]) if parts else ""
184 + address = ", ".join(parts[:-1]) if city and len(parts) > 1 else m.group(1).strip()
185 + if not city:
186 + city = _find_city(title) or _find_city(desc)
187 +
188 + # disponibilité : ligne « DISPONIBLE DÈS LE 1ER AOUT 2025 » de la fiche
189 + availability = ""
190 + m_av = re.search(r"(DISPONIBLE\s+D[ÈE]S[^\n]*|DISPONIBLE\s+(?:LE|MAINTENANT)[^\n]*)",
191 + desc, re.I)
192 + if m_av:
193 + availability = re.sub(r"\s+", " ", m_av.group(1)).strip()
194 + elif d.get("status"):
195 + availability = d["status"]
196 +
197 + amenities: list[str] = []
198 + if d.get("bedrooms", "").replace("-", "").strip().isdigit() or \
199 + re.match(r"^\d+(-\d+)?$", d.get("bedrooms", "")):
200 + amenities.append(f"{d['bedrooms']} chambre(s)")
201 + if re.match(r"^\d+([.,]\d+)?$", d.get("bathrooms", "")):
202 + amenities.append(f"{d['bathrooms']} salle(s) de bain")
203 +
204 + area = None
205 + if re.match(r"^\d{3,4}$", d.get("sqft", "")):
206 + area = float(d["sqft"])
207 +
208 + price_label = d.get("price_label", "")
209 + if not price_label:
210 + # fiche multi-typologies : « 3 1/2 À PARTIR DE 1150$ … » — on
211 + # reprend la mention la plus basse (convention Lou-Ka « à partir de »)
212 + fromtags = re.findall(r"à partir de\s*(\d[\d\s]*)\s*\$", desc, re.I)
213 + if fromtags:
214 + lo = min(int(x.replace(" ", "")) for x in fromtags)
215 + price_label = f"À partir de {lo}$"
216 + return Listing(
217 + source=self.source_id,
218 + external_id=ext,
219 + url=url,
220 + title=title,
221 + address=address,
222 + city=city,
223 + unit_type=normalize_unit_type(title),
224 + price=parse_price(price_label),
225 + price_label=price_label,
226 + availability=availability,
227 + area_sqft=area,
228 + description=desc,
229 + amenities=amenities,
230 + images=d.get("images") or [],
231 + )
added louka/connectors/lambert.py +113 −0
@@ -0,0 +1,113 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/lambert.py : connecteur Société Immobilière Lambert
5 +# (lambertimmobilier.com — Louiseville, Yamachiche, Mauricie). WordPress
6 +# avec thème custom « cognitif-starter » : la page /logements-disponibles/
7 +# liste des <article class="apartment"> (type h2, prix, <address>, photo en
8 +# background-image). On ne garde que la section « Nos logements à louer »
9 +# (les <article> suivant le titre « Projets à venir » = terrains, exclus).
10 +# Aucune page détail ; external_id = slug de l'adresse. 1 requête par sync.
11 +# -----------------------------------------------------------------------------
12 +from __future__ import annotations
13 +
14 +import re
15 +import unicodedata
16 +
17 +from bs4 import BeautifulSoup
18 +
19 +from ..schema import Listing, normalize_unit_type, parse_price
20 +from .base import BaseConnector
21 +
22 +BASE = "https://lambertimmobilier.com"
23 +LIST_URL = f"{BASE}/logements-disponibles/"
24 +
25 +_BG_URL_RE = re.compile(r"background-image\s*:\s*url\(['\"]?([^'\")]+)")
26 +_CITIES = [
27 + ("louiseville", "Louiseville"),
28 + ("yamachiche", "Yamachiche"),
29 + ("trois-rivieres", "Trois-Rivières"),
30 +]
31 +
32 +
33 +def _strip_accents(s: str) -> str:
34 + return "".join(c for c in unicodedata.normalize("NFD", s)
35 + if unicodedata.category(c) != "Mn")
36 +
37 +
38 +def _slug(s: str) -> str:
39 + s = _strip_accents(s.lower())
40 + return re.sub(r"[^a-z0-9]+", "-", s).strip("-")
41 +
42 +
43 +class LambertConnector(BaseConnector):
44 + source_id = "lambert"
45 + request_delay = 0.7
46 +
47 + def fetch(self) -> list[Listing]:
48 + html = self.get(LIST_URL).text
49 + soup = BeautifulSoup(html, "html.parser")
50 + listings: dict[str, Listing] = {}
51 + in_projects = False
52 + for el in soup.find_all(["h1", "h2", "article"]):
53 + if el.name in ("h1", "h2"):
54 + txt = el.get_text(" ", strip=True)
55 + if re.search(r"projets? à venir", txt, re.I):
56 + in_projects = True # terrains/projets : hors annonces
57 + continue
58 + if in_projects or "apartment" not in (el.get("class") or []):
59 + continue
60 + try:
61 + self._parse_card(el, listings)
62 + except Exception:
63 + continue
64 + return list(listings.values())
65 +
66 + def _parse_card(self, card, listings: dict[str, Listing]) -> None:
67 + head = card.select_one("h2")
68 + head_txt = re.sub(r"\s+", " ", head.get_text(" ", strip=True)) if head else ""
69 + if re.search(r"terrain|commercial|local\b", head_txt, re.I):
70 + return
71 + addr_el = card.find("address")
72 + address_full = re.sub(r"\s+", " ",
73 + addr_el.get_text(" ", strip=True)) if addr_el else ""
74 + if not address_full and not head_txt:
75 + return
76 +
77 + # ville en fin d'adresse (« 131 St-Ubald Louiseville »)
78 + city, address = "", address_full
79 + low = _strip_accents(address_full.lower())
80 + for key, name in _CITIES:
81 + if key in low:
82 + city = name
83 + address = re.sub(rf",?\s*{key}\s*$", "", address_full,
84 + flags=re.I).strip(" ,")
85 + break
86 +
87 + price_el = card.select_one(".apartment--price")
88 + price_label = re.sub(r"\s+", " ",
89 + price_el.get_text(" ", strip=True)) if price_el else ""
90 +
91 + images: list[str] = []
92 + img_div = card.select_one("div.img[style]")
93 + if img_div:
94 + m = _BG_URL_RE.search(img_div["style"])
95 + if m and m.group(1).startswith("http"):
96 + images.append(m.group(1))
97 +
98 + ext = _slug(address_full or head_txt)
99 + if not ext or ext in listings:
100 + return
101 + title = f"{head_txt} — {address_full}" if head_txt else address_full
102 + listings[ext] = Listing(
103 + source=self.source_id,
104 + external_id=ext,
105 + url=LIST_URL,
106 + title=title,
107 + address=address,
108 + city=city,
109 + unit_type=normalize_unit_type(head_txt),
110 + price=parse_price(price_label),
111 + price_label=price_label,
112 + images=images,
113 + )
added louka/connectors/moderno.py +215 −0
@@ -0,0 +1,215 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/moderno.py : connecteur Moderno Construction (moderno.immo)
5 +# Promoteur-gestionnaire de Lanaudière (Joliette — Aqua Roca, Domaine du
6 +# Sentier Riverain). Site custom SSR très propre : /logements-a-louer liste
7 +# des cartes BEM (titre, disponibilité, prix, adresse, badge « Loué »),
8 +# chaque carte pointe vers /logements-a-louer/<projet>/<CODE> ; le CODE
9 +# (= champ « Référence » de la fiche) sert d'external_id stable. La fiche
10 +# détail (via cache BD) ajoute chambres/sdb, unité/niveau/vue/superficie,
11 +# inclusions, commodités, description et galerie.
12 +# -----------------------------------------------------------------------------
13 +from __future__ import annotations
14 +
15 +import hashlib
16 +import re
17 +
18 +from bs4 import BeautifulSoup
19 +
20 +from ..schema import Listing, normalize_unit_type, parse_price
21 +from .base import BaseConnector
22 +
23 +BASE = "https://moderno.immo"
24 +LIST_URL = f"{BASE}/logements-a-louer"
25 +
26 +_CARD = "appartements-a-louer__liste__item"
27 +_DET = "details-appartement__details"
28 +
29 +# vignettes DigitalOcean Spaces : ".../<id>/conversions/<nom>-thumb.jpg"
30 +# -> pleine taille ".../<id>/<nom>.jpg" (même schéma que les images de cartes)
31 +_THUMB_RE = re.compile(r"/conversions/(.+?)-(?:first_)?thumb(\.\w+)$")
32 +
33 +
34 +def _full_img(url: str) -> str:
35 + return _THUMB_RE.sub(r"/\1\2", url.strip())
36 +
37 +
38 +class ModernoConnector(BaseConnector):
39 + source_id = "moderno"
40 + request_delay = 0.7
41 + max_details = 40 # garde-fou fiches détail (vraies requêtes par sync)
42 +
43 + def fetch(self) -> list[Listing]:
44 + html = self.get(LIST_URL).text
45 + soup = BeautifulSoup(html, "html.parser")
46 + listings: dict[str, Listing] = {}
47 + for card in soup.select(f"a.{_CARD}[href]"):
48 + try:
49 + self._parse_card(card, listings)
50 + except Exception:
51 + continue
52 +
53 + self._fetched = 0
54 + for lst in listings.values():
55 + key = hashlib.sha1(
56 + f"{lst.title}|{lst.price_label}|{lst.availability}|{lst.address}"
57 + .encode("utf-8")).hexdigest()
58 + try:
59 + payload = self.detail(lst.external_id, key,
60 + lambda u=lst.url: self._fetch_detail(u))
61 + except Exception:
62 + continue
63 + self._apply_detail(lst, payload)
64 + return list(listings.values())
65 +
66 + # -- carte liste ------------------------------------------------------------
67 + def _parse_card(self, card, listings: dict[str, Listing]) -> None:
68 + url = card["href"]
69 + if url.startswith("/"):
70 + url = BASE + url
71 + m = re.search(r"/logements-a-louer/([^/]+)/([A-Z0-9]+)/?$", url)
72 + if not m:
73 + return
74 + project_slug, code = m.group(1), m.group(2)
75 + if code in listings:
76 + return
77 +
78 + def _txt(suffix: str) -> str:
79 + el = card.select_one(f".{_CARD}__{suffix}")
80 + return re.sub(r"\s+", " ", el.get_text(" ", strip=True)) if el else ""
81 +
82 + # badge « Loué » : annonce déjà partie, on l'ignore
83 + if re.search(r"lou[ée]", _txt("rented"), re.I):
84 + return
85 +
86 + title = _txt("titre") # « 4 1/2 - Domaine du Sentier Riverain »
87 + address_full = _txt("adresse") # « 105-1002 rue Gustave-Guertin, Joliette »
88 + parts = [p.strip() for p in address_full.split(",") if p.strip()]
89 + address = re.sub(r"\s+", " ", parts[0]) if parts else ""
90 + city = parts[-1] if len(parts) > 1 else ""
91 + price_label = _txt("prix") # « 1 915 $ par mois »
92 + message = _txt("message") # accroche rédigée par Moderno
93 +
94 + images: list[str] = []
95 + img = card.select_one("img[src]")
96 + if img and img["src"].startswith("http"):
97 + images.append(_full_img(img["src"]))
98 +
99 + listings[code] = Listing(
100 + source=self.source_id,
101 + external_id=code, # champ « Référence » de la fiche
102 + url=url,
103 + title=title,
104 + address=address,
105 + city=city,
106 + unit_type=normalize_unit_type(title),
107 + price=parse_price(price_label),
108 + price_label=price_label,
109 + availability=_txt("disponibilite"), # « Disponible 1er octobre 2026 »
110 + description=message,
111 + details={"project": project_slug},
112 + images=images,
113 + )
114 +
115 + # -- fiche détail -------------------------------------------------------------
116 + def _fetch_detail(self, url: str) -> dict:
117 + """Chambres/sdb, chiffres (unité, niveau, vue, superficie, dispo),
118 + inclusions + commodités, description et galerie pleine taille."""
119 + if self._fetched >= self.max_details:
120 + raise RuntimeError("budget de fiches détail atteint")
121 + self._fetched += 1
122 + html = self.get(url).text
123 + soup = BeautifulSoup(html, "html.parser")
124 + out: dict = {}
125 +
126 + det = soup.select_one(f"section.{_DET}")
127 + if det is None:
128 + return out
129 +
130 + # adresse complète de la fiche (parfois plus propre que la carte)
131 + addr = det.select_one(f".{_DET}__adresse")
132 + if addr:
133 + out["address_full"] = re.sub(r"\s+", " ", addr.get_text(" ", strip=True))
134 +
135 + # « 1 chambre », « 1 salle de bain »
136 + out["pieces"] = [re.sub(r"\s+", " ", p.get_text(" ", strip=True))
137 + for p in det.select(f".{_DET}__pieces__piece")]
138 +
139 + # paires libellé/valeur : Unité, Niveau, Vue, Superficie, Disponibilité, Référence
140 + chiffres: dict[str, str] = {}
141 + for div in det.select(f".{_DET}__chiffres > div"):
142 + ps = div.find_all("p")
143 + if len(ps) >= 2:
144 + lab = ps[0].get_text(" ", strip=True)
145 + val = re.sub(r"\s+", " ", ps[1].get_text(" ", strip=True))
146 + if lab and val:
147 + chiffres[lab] = val
148 + out["chiffres"] = chiffres
149 +
150 + # Inclusions (eau chaude, internet…) et Commodités (logement + immeuble)
151 + feats: list[str] = []
152 + for box in det.select(f".{_DET}__inclusions, .{_DET}__commodites"):
153 + items = [re.sub(r"\s+", " ", li.get_text(" ", strip=True))
154 + for li in box.find_all("li")]
155 + if not items: # listes parfois en <p>
156 + items = [re.sub(r"\s+", " ", p.get_text(" ", strip=True))
157 + for p in box.find_all("p")][1:]
158 + head = box.find(["h4", "h5", "div"])
159 + head_txt = head.get_text(" ", strip=True) if head else ""
160 + if re.match(r"(?i)localisation", head_txt):
161 + continue # adresse déjà captée
162 + for it in items:
163 + if it and it not in feats and not re.match(r"(?i)inclusions|commodit|caractéristiques|localisation", it):
164 + feats.append(it)
165 + out["features"] = feats[:40]
166 +
167 + # description : accroche + paragraphes rédigés de la fiche
168 + msg = det.select_one(f".{_DET}__message")
169 + paras = [msg.get_text(" ", strip=True)] if msg else []
170 + for p in det.find_all("p"):
171 + t = re.sub(r"\s+", " ", p.get_text(" ", strip=True))
172 + if len(t) > 60 and t not in paras:
173 + paras.append(t)
174 + out["description"] = "\n".join(paras)[:1200]
175 +
176 + # galerie (vignettes /conversions/ -> pleine taille)
177 + images: list[str] = []
178 + for img in soup.select(".details-appartement__introduction__galerie img[src]"):
179 + u = _full_img(img["src"])
180 + if u.startswith("http") and u not in images:
181 + images.append(u)
182 + out["images"] = images[:30]
183 + return out
184 +
185 + def _apply_detail(self, lst: Listing, d: dict) -> None:
186 + if not d:
187 + return
188 + ch = d.get("chiffres") or {}
189 + details = dict(lst.details)
190 + for lab, key in (("Unité", "unit_number"), ("Niveau", "floor"),
191 + ("Vue", "view"), ("Référence", "reference")):
192 + if ch.get(lab):
193 + details[key] = ch[lab]
194 + lst.details = details
195 + m = re.match(r"([\d\s,.]+)\s*pi", ch.get("Superficie", ""))
196 + if m:
197 + try:
198 + lst.area_sqft = float(m.group(1).replace(" ", "").replace(",", ""))
199 + except ValueError:
200 + pass
201 + if ch.get("Disponibilité") and not lst.availability:
202 + lst.availability = ch["Disponibilité"]
203 + if d.get("address_full") and not lst.address:
204 + parts = [p.strip() for p in d["address_full"].split(",")]
205 + lst.address = parts[0]
206 + if len(parts) > 1 and not lst.city:
207 + lst.city = parts[-1]
208 + extra = (d.get("pieces") or []) + (d.get("features") or [])
209 + if extra:
210 + lst.amenities = list(dict.fromkeys(lst.amenities + extra))
211 + if d.get("description"):
212 + lst.description = d["description"]
213 + if d.get("images"):
214 + merged = d["images"] + [u for u in lst.images if u not in d["images"]]
215 + lst.images = merged[:30]
added reports/connectors/acceslogis_gb.md +43 −0
@@ -0,0 +1,43 @@
1 +# acceslogis_gb — Accès Logis GB (acceslogisgb.com)
2 +- site: https://acceslogisgb.com (builder mono-page, HTML plat sans charset déclaré — décodé UTF-8 à la main)
3 +- méthode: html — 1 seule requête par sync (page d'accueil, section « LOGEMENTS DISPONIBLES »)
4 +- annonces: 11 (Sainte-Élisabeth ×2, Joliette ×1, Saint-Ambroise-de-Kildare ×3, Shawinigan ×5 ; 2 cartes mini-entrepôts exclues)
5 +- couverture (sur 11): prix 100 %, type 100 %, dispo 100 % (« Disponible maintenant »), ville 100 %, description 100 %, image 100 % (1/carte), adresse 27 % (publiée seulement quand le n° civique figure au texte)
6 +- fixture: ok (1 requête) · test: ok (2 verts) · sync: 11 ajoutées
7 +
8 +## Champs extraits
9 +- Cartes = colonnes des grilles `div.columnswithgap-02` :
10 + - titre `<p.font-026><b>` (« 3 1/2, Rang du Ruisseau, Ste-Élisabeth ») ;
11 + - description `<p.font-014>` dont on **extrait les <span>** de fin :
12 + « Disponible maintenant » → availability, « 1150$ » → price_label
13 + (le « PAR MOIS » résiduel est retiré de la description) ;
14 + - photo relative → URL absolue ;
15 + - unit_type : titre puis repli description (validé par fullmatch, jamais le
16 + titre brut) ; les titres-adresses (« 84 Rue Précieux-Sang ») passent par
17 + la description (« Logement 4 1/2 au 1er étage ») ;
18 + - adresse : motif « situé au <n° civique …> à » de la description, sinon
19 + titre commençant par un n° civique ;
20 + - ville : dictionnaire (Ste-Élisabeth, St-Ambroise-de-Kildare, Joliette,
21 + Shawinigan, Crabtree, Berthierville…) sur titre+description sans accents.
22 +- external_id = slug du titre + **repère d'étage** de la description
23 + (« demi sous-sol », « 2e étage »…) car les titres se répètent entre étages
24 + d'un même immeuble ; ultime repli = hash du texte (jamais atteint à ce jour).
25 +- Superficie (« 1050 pieds carrés ») : laissée à `finalize()` (description).
26 +
27 +## Champs indisponibles à la source
28 +- Pages détail, superficies systématiques, GPS, dates de dispo précises
29 + (tout est « Disponible maintenant »), animaux/meublé.
30 +
31 +## Fragilités
32 +- Exclusion par mots-clés « entrepôt/commercial/local » : une nouvelle
33 + catégorie hors-logement demanderait un mot-clé de plus.
34 +- Si deux logements identiques (même titre, même étage) coexistaient un jour,
35 + le second recevrait un suffixe hash — id stable tant que le texte l'est.
36 +- Serveur sans charset : si le site migrait d'encodage, l'UTF-8 forcé
37 + casserait visiblement (test fixture).
38 +
39 +## Échantillon
40 +- 3-1-2-rang-du-ruisseau-ste-elisabeth : 3½, 2510 Rang du Ruisseau,
41 + Sainte-Élisabeth, 1150 $/mois, disponible maintenant.
42 +- 4-1-2-rue-frigon-a-shawinigan-3e-etage : 4½ neuf, rue Frigon Shawinigan,
43 + 1350 $/mois, disponible maintenant.
added reports/connectors/ferrovia.md +40 −0
@@ -0,0 +1,40 @@
1 +# ferrovia — Ferrovia (ferroviamirabel.com)
2 +- site: https://www.ferroviamirabel.com (WordPress + wpDataTables rendues côté serveur ; projet de condos locatifs neufs à Mirabel, secteur Saint-Janvier, 4 phases)
3 +- méthode: html — 3 requêtes par sync (pages « Disponibilités » phases 1, 3 et 4 ; la phase 2 est « à venir », sans page)
4 +- annonces: 23 (phase 3 ×1 « ÉTÉ 2026 », phase 4 ×22 « Automne 2026 » ; les lignes « Loué » — la quasi-totalité des phases 1 et 3 — sont filtrées)
5 +- couverture (sur 23): type 100 %, superficie 100 %, dispo 100 %, ville/secteur 100 %, étage/modèle 100 %, plan PDF ~87 % — **prix 0 % : la colonne PRIX existe mais est vide sur le site** (jamais inventé)
6 +- fixture: ok (3 requêtes) · test: ok (2 verts) · sync: 23 ajoutées
7 +
8 +## Champs extraits
9 +- `<table.wpDataTable>` de chaque page phase, colonnes repérées par leur
10 + en-tête (wdt_ID, UNITÉ, MODÈLE, ÉTAGE, PIÈCES, SUPERFICIE p.c.,
11 + SALLE D'EAU SUPP., DISPONIBILITÉ, PRIX, STATUT, PLAN) :
12 + - STATUT ≠ « Disponible » → ligne ignorée (Loué) ;
13 + - external_id = `phase<N>-<unité>` (ex. phase4-101) ;
14 + - PIÈCES (« 4 1/2 ») → unit_type (fullmatch, lignes non résidentielles
15 + ignorées) ; SUPERFICIE → area_sqft ; DISPONIBILITÉ (« Automne 2026 »)
16 + → availability ; MODÈLE/ÉTAGE → details ; lien PDF de la colonne PLAN
17 + → details.plan_pdf ; « SALLE D'EAU SUPP. = OUI » → commodité.
18 +- Note du site reprise fidèlement : « les logements sont non-fumeurs et les
19 + animaux ne sont pas admis » → pets="non", details.smoking=false,
20 + et phrase en description.
21 +- Ville/secteur : Mirabel / Saint-Janvier (énoncé en tête de chaque page ;
22 + aucune adresse civique publiée sur les pages disponibilités).
23 +
24 +## Champs indisponibles à la source
25 +- **Prix** (colonne vide à ce jour — le connecteur les remontera dès qu'ils
26 + seront saisis), adresse civique, photos par unité (la page « photos des
27 + unités » est générique, non associée), GPS.
28 +
29 +## Fragilités
30 +- wpDataTables sert ici le HTML complet dans la page ; si le site passait au
31 + chargement AJAX (option du plugin), la table serait vide (test fixture).
32 +- Les en-têtes de colonnes sont mappés par mot-clé : un renommage majeur
33 + (« PIÈCES » → autre) casserait proprement.
34 +- Livraisons « Automne 2026 » : parse_availability_date ne sait pas convertir
35 + une saison — availability_date restera nulle, le texte brut est conservé.
36 +
37 +## Échantillon
38 +- phase4-101 : 4½ modèle F, 1er étage, 1200 pi², salle d'eau supp.,
39 + dispo Automne 2026, plan PDF.
40 +- phase3-603 : 3½ modèle E', 6e étage, 950 pi², dispo ÉTÉ 2026.
added reports/connectors/fournelle.md +40 −0
@@ -0,0 +1,40 @@
1 +# fournelle — Appartements Fournelle / Groupe Fournelle (groupefournelle.com)
2 +- site: https://www.groupefournelle.com/appartements-fournelle/ (WordPress, page-brochure par projets ; constructeur-gestionnaire de Bécancour)
3 +- méthode: html — 1 requête par sync ; annonces = lignes de prix par unité du bloc « Domaine de l'Île » (les blocs « Pour information : … » sans prix ne produisent rien)
4 +- annonces: 4 (immeuble neuf 8plex « vue sur le fleuve » : 2×5½ sous-sol 1300 $, 2×5½ RDC 1525 $, 2×4½ 2e étage 1400 $, 2×5½ 3e étage 1525 $)
5 +- couverture (sur 4): prix 100 %, type 100 %, dispo 100 % (« disponible à partir du 1er Mai 2026 »), ville 100 %, commodités 100 %, images 100 % (photos + plans), description 100 % — adresse civique jamais publiée
6 +- fixture: ok (1 requête) · test: ok (2 verts) · sync: 4 ajoutées
7 +
8 +## Champs extraits
9 +- Blocs `div.item > div.content` : h2 = titre du projet, lien → slug projet.
10 +- Motif « <n> × <type ½> au <étage> – <prix $> » (regex tolérante aux tirets
11 + – et - ; l'étage peut contenir un trait d'union, « sous-sol » — le prix est
12 + ancré sur un chiffre) → **une annonce par ligne typologie/étage** ;
13 + external_id = slug projet+type+étage (domaine-de-lile-512-sous-sol) ;
14 + details.units_on_line conserve le « 2 × » (nombre d'unités de la ligne).
15 +- Disponibilité : « disponible à partir du 1er Mai 2026 » (texte du bloc).
16 +- `<ul>` du bloc → amenities (îlot de cuisine, thermopompe, terrasse…) ;
17 + phrases de conditions (« Immeuble sans fumée et sans animaux », « non
18 + chauffé, non éclairé », « Enquête de crédit… ») → description (l'extraction
19 + commune en déduit animaux/fumeur — rien de deviné dans le connecteur).
20 +- Galerie swiper du bloc (photos + plans pleine taille) → images (15 max).
21 +- Ville : Bécancour — titre de la page (« Appartements à louer à Bécancour »).
22 +
23 +## Champs indisponibles à la source
24 +- Adresse civique (le site situe le développement « rue des Muguets, secteur
25 + Ste-Angèle » dans un autre paragraphe, sans n° civique — non rattaché
26 + d'office au bloc), superficie, GPS, unités individuelles (l'inventaire
27 + est agrégé par ligne de prix).
28 +
29 +## Fragilités
30 +- Brochure rédigée à la main : toute reformulation des lignes de prix casse
31 + le motif (le test fixture le détecterait) ; c'est le format du site depuis
32 + la mise en ligne du projet.
33 +- Si une ligne disparaît (étage complet loué), l'annonce est retirée au diff —
34 + comportement voulu.
35 +
36 +## Échantillon
37 +- domaine-de-lile-512-sous-sol : 5½ au sous-sol, 1300 $/mois, dispo 1er mai
38 + 2026, stationnement inclus, 15 visuels (photos, plans).
39 +- domaine-de-lile-412-2-e-etage : 4½ au 2e étage, 1400 $/mois, vue sur le
40 + fleuve.
added reports/connectors/habitations_sf.md +46 −0
@@ -0,0 +1,46 @@
1 +# habitations_sf — Les Habitations SF / S&F Gestion (leshabitationssf.com)
2 +- site: https://www.leshabitationssf.com (Wix — mais les pages dynamiques `/copy-of-location/<slug>` de la collection d'annonces sont rendues côté serveur : parsable sans JS ni Firecrawl)
3 +- méthode: html — accueil (répéteur d'annonces) + 1 fiche/annonce via cache BD (clé = hash des liens + textes du répéteur : tout changement d'accueil déclenche la relecture des fiches)
4 +- annonces: 7 (Saint-Charles-Borromée ×5, Piedmont ×1, Saint-Félix-de-Valois ×1)
5 +- couverture (sur 7): type 100 %, ville 100 %, dispo 100 %, prix 86 % (une fiche multi-typologies « à partir de », une fiche sans prix publié), chambres/sdb 100 %, images 100 %, description ~86 %, adresse 43 % (rue sans n° civique quand publiée)
6 +- fixture: ok (8 requêtes) · test: ok (2 verts) · sync: 7 ajoutées
7 +
8 +## Champs extraits
9 +- Index : liens `a[href*="copy-of-location/"]` de l'accueil (dédupliqués,
10 + ordre conservé) ; **external_id = slug d'URL décodé et translittéré**
11 + (« appartement-3-1%2F2-%C3%A0-louer » → appartement-3-1-2-a-louer).
12 +- Fiche (page dynamique Wix, éléments `[data-testid="richTextElement"]`) :
13 + - `<title>` → title (« APPARTEMENT 3 1/2 À LOUER À SAINT-CHARLES BORROMÉE »)
14 + → unit_type ;
15 + - statut isolé (« Disponible » / « Loué » / « Réservé ») → les unités
16 + parties sont filtrées ;
17 + - séquence structurée : valeur précédant « Chambre(s) », « Salle(s) de
18 + bain », « Pieds² » → amenities/area ; « <n> $ / mois » (ou « <n> / mois »
19 + sans $, variante du site) → price_label ;
20 + - plus long bloc riche (>200 car.) → description ; sa ligne « Adresse: … »
21 + → address + city (dictionnaire de villes sans accents/espaces) ; sa ligne
22 + « DISPONIBLE DÈS LE … » → availability ;
23 + - fiche multi-typologies (« 3 1/2 À PARTIR DE 1150$… ») → price_label
24 + « À partir de <min>$ » (convention Lou-Ka du prix plancher) ;
25 + - galerie : images wixstatic grand format (w_900+), dédupliquées par id de
26 + média `…~mv2` (le carrousel duplique chaque visuel).
27 +- Animaux (« Animaux acceptés sous certaines conditions ») : laissés à
28 + l'extraction commune sur la description (jamais devinés ici).
29 +
30 +## Champs indisponibles à la source
31 +- N° civiques complets, GPS, superficies (champ « Pieds² » vide sur toutes
32 + les fiches actuelles).
33 +
34 +## Fragilités
35 +- Wix : refonte du gabarit = re-travail ; le rendu serveur des pages
36 + dynamiques est un comportement Wix standard depuis 2020 mais pas garanti.
37 +- Les dates « DISPONIBLE DÈS LE 1ER AOUT 2025 » datent parfois de la mise en
38 + ligne (fiches encore affichées comme Disponible) : texte source conservé
39 + tel quel.
40 +- Pages lourdes (~1,8 Mo) : le cache BD limite les relectures ; délai 0,8 s.
41 +
42 +## Échantillon
43 +- appartement-3-1-2-a-louer : 3½ rue de la Visitation, Saint-Charles-Borromée,
44 + 1150 $/mois, construction neuve 2025, 8 photos.
45 +- condo-4-1-2-a-louer : 4½ à Piedmont, 1 800 $/mois, « Disponible dès
46 + maintenant », 20 photos.
added reports/connectors/lambert.md +32 −0
@@ -0,0 +1,32 @@
1 +# lambert — Société Immobilière Lambert (lambertimmobilier.com)
2 +- site: https://lambertimmobilier.com (WordPress, thème custom « cognitif-starter » ; petit gestionnaire de Louiseville, actif depuis 2018)
3 +- méthode: html — 1 requête par sync (/logements-disponibles/)
4 +- annonces: 2 (131 St-Ubald Louiseville 4½ 1250 $/mois ; 331 rue Milette Yamachiche — carte sans type ni prix, publiée telle quelle)
5 +- couverture (sur 2): adresse 100 %, ville 100 %, image 100 %, prix 50 %, type 50 % — dispo/description jamais publiées
6 +- fixture: ok (1 requête) · test: ok (2 verts) · sync: 2 ajoutées
7 +
8 +## Champs extraits
9 +- Page parcourue en ordre de document (`h1/h2/article`) : les `<article
10 + class="apartment">` situés **après le titre « Projets à venir » sont
11 + ignorés** (terrains) ; ceinture-bretelles : titres « Terrain/commercial/
12 + local » exclus aussi.
13 +- Carte : `h2.h6` (« 4 1/2 ») → unit_type ; `.apartment--price`
14 + (« 1250$ / mois ») → price_label + parse_price ; `<address>` → adresse +
15 + ville (suffixe Louiseville/Yamachiche/Trois-Rivières détaché) ; photo en
16 + `background-image` du `div.img`.
17 +- external_id = slug de l'adresse (131-st-ubald-louiseville) — stable, pas
18 + d'URL par annonce sur le site ; url = page liste.
19 +
20 +## Champs indisponibles à la source
21 +- Disponibilité, description, superficie, commodités, GPS, pages détail.
22 +
23 +## Fragilités
24 +- Parc minuscule (2 cartes) : si tout est loué, le connecteur remontera 0
25 + annonce (l'alerte de sync le signalera) — c'est l'état réel du site.
26 +- La carte « 331 rue Milette » n'a ni type ni prix : conservée avec champs
27 + vides (rien d'inventé) ; si l'agence enrichit la carte, les champs suivront.
28 +
29 +## Échantillon
30 +- 131-st-ubald-louiseville : 4½ — 131 St-Ubald, Louiseville, 1250 $/mois,
31 + 1 photo.
32 +- 331-rue-milette-yamachiche : 331, rue Milette, Yamachiche, 1 photo.
added reports/connectors/moderno.md +45 −0
@@ -0,0 +1,45 @@
1 +# moderno — Moderno Construction (moderno.immo)
2 +- site: https://moderno.immo (custom SSR très propre, BEM ; promoteur-gestionnaire de Lanaudière)
3 +- méthode: html — liste /logements-a-louer (cartes BEM) + fiches détail via cache BD (1 requête liste + 1/fiche nouvelle ou modifiée, budget 40)
4 +- annonces: 6 (Aqua Roca phase 1 à Joliette ×3, Domaine du Sentier Riverain à Joliette ×3 ; 2 cartes « Loué » filtrées)
5 +- couverture (sur 6): prix 100 %, type 100 %, adresse 100 %, ville 100 %, dispo 100 %, superficie 100 %, images 100 %, commodités 100 %, description 100 %
6 +- fixture: ok (7 requêtes) · test: ok (2 verts) · sync: 6 ajoutées
7 +
8 +## Champs extraits
9 +- Liste : `a.appartements-a-louer__liste__item[href]` →
10 + - href `/logements-a-louer/<projet>/<CODE>` : **external_id = CODE**
11 + (= champ « Référence » affiché sur la fiche, stable) ;
12 + - `__titre` (« 4 1/2 - Domaine du Sentier Riverain ») → title + unit_type ;
13 + - `__adresse` (« 105-1002 rue Gustave-Guertin, Joliette ») → address + city ;
14 + - `__prix` (« 1 945 $ par mois ») → price_label + parse_price ;
15 + - `__disponibilite` (« Disponible 1er octobre 2026 ») → availability ;
16 + - `__rented` (« Loué ») → annonce ignorée ;
17 + - `__message` (accroche) → description provisoire ;
18 + - image de carte (DigitalOcean Spaces).
19 +- Fiche détail (cache BD, clé = hash titre|prix|dispo|adresse) :
20 + - `__pieces__piece` (« 1 chambre », « 1 salle de bain ») → amenities ;
21 + - bloc `__chiffres` (paires libellé/valeur) : Unité, Niveau, Vue → details ;
22 + Superficie (« 957 pi² ») → area_sqft ; Disponibilité/Référence croisées ;
23 + - Inclusions + Commodités (logement & immeuble) → amenities
24 + (bloc « Localisation » exclu, adresse déjà captée) ;
25 + - accroche + paragraphes rédigés → description ;
26 + - galerie : vignettes `…/conversions/<nom>-thumb.jpg` réécrites en pleine
27 + taille `…/<nom>.jpg` (même schéma que les images de cartes).
28 +
29 +## Champs indisponibles à la source
30 +- GPS, animaux/meublé structurés (les commodités mentionnent « Animal de
31 + compagnie permis (selon les règlements) » — textmine s'en charge).
32 +
33 +## Fragilités
34 +- Classes BEM stables mais custom : refonte du site = refonte du connecteur
35 + (le test fixture le détecterait).
36 +- Certaines URLs d'images contiennent des caractères non-ASCII (« Fa‡ade ») :
37 + conservées telles quelles (requests les encode à l'usage).
38 +- La page liste porte des filtres côté client (immeuble/type/budget) : la
39 + liste complète est rendue côté serveur, aucun JS requis.
40 +
41 +## Échantillon
42 +- EXK77 : 3½ Aqua Roca ph. 1, 307-99 rue du Cabastran Joliette, 1 955 $/mois,
43 + 957 pi², dispo 1er sept. 2026, niveau 3e, vue façade, 5 photos.
44 +- MOEAN : 4½ Domaine du Sentier Riverain, 106-964 rue Gustave-Guertin,
45 + 2 020 $/mois, 1 100 pi², « Disponible Maintenant », 16 photos.
added tests/fixtures/acceslogis_gb/3e3670fb0276a0d9e5cd.html +928 −0
@@ -0,0 +1,928 @@
1 +<!DOCTYPE html>
2 +<html lang="fr">
3 +<head>
4 +<title>Acces Logis GB Joliette | Logement à louer Joliette, Crabtree et Berthier</title>
5 +<meta name="description" content="Appartement et logement à louer à Joliette, Crabtree, Charlemagne, Ste-Elisabeth, St-Ambroise et Berthier dans Lanaudière.">
6 +<meta name="robots" content="index, follow">
7 +<meta name="format-detection" content="telephone=no">
8 +<meta charset="UTF-8">
9 +<meta name="viewport" content="width=device-width, initial-scale=1.0">
10 +<link rel="icon" type="image/png" href="favicon.png">
11 +<link href="https://fonts.googleapis.com/css?family=Bebas+Neue|Open+Sans|Roboto|Roboto+Condensed|Sulphur+Point|Ubuntu|Nanum+Gothic|Raleway&display=swap" rel="stylesheet">
12 +<link href="https://fonts.googleapis.com/css?family=Courgette|Damion|Dancing+Script|Kaushan+Script|Pacifico|Pinyon+Script|Tangerine&display=swap" rel="stylesheet">
13 +<link rel="stylesheet" type="text/css" href="buttons.css">
14 +<link rel="stylesheet" type="text/css" href="colors.css">
15 +<link rel="stylesheet" type="text/css" href="fonts.css">
16 +<link rel="stylesheet" type="text/css" href="footer.css">
17 +<link rel="stylesheet" type="text/css" href="header.css">
18 +<link rel="stylesheet" type="text/css" href="slider.css">
19 +<link rel="stylesheet" type="text/css" href="spacers.css">
20 +<link rel="stylesheet" type="text/css" href="style.css">
21 +<script src="https://www.google.com/recaptcha/api.js" async defer></script>
22 +</head>
23 +
24 +
25 +
26 +
27 +
28 +
29 +<body>
30 +
31 +<header>
32 +
33 +<nav class="backgroundcolor-white">
34 +
35 +<div class="header-menu">
36 +
37 + <div class="">
38 + <a class="" href="index.html">
39 + <img class="header-logo imagehover-01" src="acces-logis-gb_500px.png" alt="Acces Logis GB">
40 + </a>
41 + </div>
42 +
43 + <div class="navfont-menu">
44 + <a href="index.html#logements">Logements disponibles</a>
45 + <div class="header-spacer-01"></div>
46 + <div class="header-line"></div>
47 + <div class="header-spacer-01"></div>
48 + <a href="multiplex-joliette-crabtree.html">Nos immeubles</a>
49 + <div class="header-spacer-01"></div>
50 + <div class="header-line"></div>
51 + <div class="header-spacer-01"></div>
52 + <a href="location-de-logement-joliette-crabtree.html">Demande de location</a>
53 + <div class="header-spacer-01"></div>
54 + <div class="header-line"></div>
55 + <div class="header-spacer-01"></div>
56 + <a href="/mini-entrepots/index.html">Entrepôts</a>
57 + <div class="header-spacer-01"></div>
58 + <div class="header-line"></div>
59 + <div class="header-spacer-01"></div>
60 + <a href="#contact">Contact</a>
61 + </div>
62 +
63 +</div>
64 +
65 +</nav>
66 +
67 +</header>
68 +
69 +
70 +
71 +
72 +<div class="slider">
73 +
74 + <div class="slider-absolute-01">
75 + <p class="bebasneue slider-font-01 white" style="opacity:1.00;">
76 + Pour un logement<br>
77 + adapté à votre<br>
78 + style de vie
79 + <div class="spacer-001"></div>
80 + <a class="slider-button-02" href="#logements">LOGEMENTS DISPONIBLES&nbsp; &#10095;</a>
81 + <a name=""></a>
82 + </p>
83 +
84 + </div>
85 + <div id="slider2images">
86 + <figure>
87 + <img src="multiplex-joliette-crabtree_001.jpg" style="min-height:220px;max-height:700px;object-fit:cover;object-position:center top;">
88 + <img src="multiplex-joliette-crabtree_002.jpg" style="min-height:220px;max-height:700px;object-fit:cover;object-position:center top;">
89 + </figure>
90 + </div>
91 +
92 +
93 +
94 +</div>
95 +
96 +
97 +
98 +<main>
99 +
100 +<a name="logements"></a>
101 +
102 +<div class="spacer-050px"></div>
103 +
104 +
105 +<div class="container-1200px">
106 +
107 +<br>
108 +<br>
109 +
110 + <div class="padding-015px aligncenter">
111 + <p class="opensans font-045 blau-03">
112 + LOGEMENTS DISPONIBLES
113 + </p>
114 + </div>
115 +
116 +
117 +<br>
118 +
119 +
120 +
121 +
122 +
123 +
124 +<div class="columnswithgap-02">
125 +
126 + <div class="">
127 + <p class="opensans font-026 font-hover-01">
128 +
129 + <b>
130 + 3 1/2, Rang du Ruisseau, Ste-Élisabeth
131 + </b>
132 +
133 + </p>
134 +
135 + <img class="imagehover-02" src="logements-a-louer-lanaudiere/logement-a-louer-sainte-elisabeth_003.jpg" alt="Logement à louer">
136 +
137 + <div class="spacer-010px"></div>
138 + <p class="opensans font-014" style="opacity:0.71;">
139 + Logement 3 1/2 à louer, situé au 2510 Rang du Ruisseau à Sainte-Élizabeth, une municipalité en
140 + périphérie de la ville de Joliette, tout près de nombreux services. Environnement tranquille. Grande luminosité.
141 + <br>
142 + <span class="opensans font-016">
143 + <b>
144 + Disponible maintenant
145 + </b>
146 + </span>
147 + <br>
148 + <span class="opensans font-020">
149 + <b>
150 + 1150$
151 + </b>
152 + </span>
153 + <span class="opensans font-014">
154 + PAR MOIS
155 + </span>
156 + </p>
157 +
158 + <a class="button-slim" href="location-de-logement-joliette-crabtree.html">DEMANDE DE LOCATION&nbsp; &#10095;</a>
159 +
160 + <div class="spacer-002"></div>
161 + </div>
162 +
163 + <div></div>
164 +
165 + <div class="">
166 + <p class="opensans font-026 font-hover-01">
167 +
168 + <b>
169 + 4 1/2, Rang du Ruisseau, Ste-Élisabeth
170 + </b>
171 +
172 + </p>
173 +
174 + <img class="imagehover-02" src="logements-a-louer-lanaudiere/logement-a-louer-sainte-elisabeth_004.jpg" alt="Logement a louer">
175 +
176 + <div class="spacer-010px"></div>
177 + <p class="opensans font-014" style="opacity:0.71;">
178 + Logement 4 1/2 de 1050 pieds carrés à louer, situé au 2510 Rang du Ruisseau à Sainte-Élizabeth, une municipalité en
179 + périphérie de la ville de Joliette, tout près de nombreux services. Environnement tranquille.
180 + <br>
181 + <span class="opensans font-016">
182 + <b>
183 + Disponible maintenant
184 + </b>
185 + </span>
186 + <br>
187 + <span class="opensans font-020">
188 + <b>
189 + 1295$
190 + </b>
191 + </span>
192 + <span class="opensans font-014">
193 + PAR MOIS
194 + </span>
195 + </p>
196 +
197 + <a class="button-slim" href="location-de-logement-joliette-crabtree.html">DEMANDE DE LOCATION&nbsp; &#10095;</a>
198 +
199 + <div class="spacer-005"></div>
200 + </div>
201 +
202 +</div>
203 +
204 +
205 +
206 +<br>
207 +<br>
208 +
209 +
210 +
211 +
212 +<div class="columnswithgap-02">
213 +
214 + <div class="">
215 + <p class="opensans font-026 font-hover-01">
216 +
217 + <b>
218 + 84 Rue Précieux-Sang, Joliette
219 + </b>
220 +
221 + </p>
222 +
223 + <img class="imagehover-02" src="logements-a-louer-lanaudiere/logement-a-louer-joliette_001.jpg" alt="Logement a louer">
224 +
225 + <div class="spacer-010px"></div>
226 + <p class="opensans font-014" style="opacity:0.71;">
227 + Logement 4 1/2 au 1er étage à Joliette. Stationnement, remise extérieure, échangeur d'air, grand balcon privé,
228 + grands espaces lumineux et cuisine avec îlot. <i>Les chats peuvent être acceptés sous certaines conditions.</i>
229 + <br>
230 + <span class="opensans font-016">
231 + <b>
232 + Disponible maintenant
233 + </b>
234 + </span>
235 + <br>
236 + <span class="opensans font-020">
237 + <b>
238 + 1250$
239 + </b>
240 + </span>
241 + <span class="opensans font-014">
242 + PAR MOIS
243 + </span>
244 + </p>
245 +
246 +
247 + <a class="button-slim" href="location-de-logement-joliette-crabtree.html">DEMANDE DE LOCATION&nbsp; &#10095;</a>
248 +
249 + <div class="spacer-002"></div>
250 + </div>
251 +
252 +
253 +
254 + <div></div>
255 +
256 +
257 +
258 + <div class="">
259 + <p class="opensans font-026 font-hover-01">
260 +
261 + <b>
262 + 5 1/2 à St-Ambroise-de-Kildare
263 + </b>
264 +
265 + </p>
266 +
267 + <img class="imagehover-02" src="logements-a-louer-lanaudiere/logement-a-louer-saint-ambroise-de-kildare_003.jpg" alt="Logement St-Ambroise-de Kildare">
268 +
269 + <div class="spacer-010px"></div>
270 + <p class="opensans font-014" style="opacity:0.71;">
271 + Grand appartement 5 1/2 sur l'avenue des Commissaires à Saint-Ambroise de Kildare. Insonorisation et isolation aux dernières normes.
272 + Stationnement, remise, internet, air conditionné, grand balcon privé et aspirateur central inclus. Chat accepté.
273 + <br>
274 + <span class="opensans font-016">
275 + <b>
276 + Disponible maintenant
277 + </b>
278 + </span>
279 + <br>
280 + <span class="opensans font-020">
281 + <b>
282 + 1500$
283 + </b>
284 + </span>
285 + <span class="opensans font-014">
286 + PAR MOIS
287 + </span>
288 + </p>
289 +
290 + <a class="button-slim" href="location-de-logement-joliette-crabtree.html">DEMANDE DE LOCATION&nbsp; &#10095;</a>
291 +
292 + <div class="spacer-005"></div>
293 + </div>
294 +
295 +</div>
296 +
297 +
298 +<br>
299 +<br>
300 +
301 +
302 +
303 +<div class="columnswithgap-02">
304 +
305 + <div class="">
306 + <p class="opensans font-026 font-hover-01">
307 +
308 + <b>
309 + 4 1/2 à St-Ambroise-de-Kildare
310 + </b>
311 +
312 + </p>
313 +
314 + <img class="imagehover-02" src="logements-a-louer-lanaudiere/logement-a-louer-saint-ambroise-de-kildare_002.jpg" alt="Logement St-Ambroise-de Kildare">
315 +
316 + <div class="spacer-010px"></div>
317 + <p class="opensans font-014" style="opacity:0.71;">
318 + Grand appartement 4 1/2 en demi sous-sol sur l'avenue des Commissaires à Saint-Ambroise de Kildare. Insonorisation et isolation aux dernières normes.
319 + Stationnement, remise, internet, air conditionné, grand balcon privé et aspirateur central inclus. Chat accepté.
320 + <br>
321 + <span class="opensans font-016">
322 + <b>
323 + Disponible maintenant
324 + </b>
325 + </span>
326 + <br>
327 + <span class="opensans font-020">
328 + <b>
329 + 1325$
330 + </b>
331 + </span>
332 + <span class="opensans font-014">
333 + PAR MOIS
334 + </span>
335 + </p>
336 +
337 + <a class="button-slim" href="location-de-logement-joliette-crabtree.html">DEMANDE DE LOCATION&nbsp; &#10095;</a>
338 +
339 + <div class="spacer-002"></div>
340 + </div>
341 +
342 +
343 + <div></div>
344 +
345 +
346 + <div class="">
347 + <p class="opensans font-026 font-hover-01">
348 +
349 + <b>
350 + 4 1/2 à St-Ambroise-de-Kildare
351 + </b>
352 +
353 + </p>
354 +
355 + <img class="imagehover-02" src="logements-a-louer-lanaudiere/logement-a-louer-saint-ambroise-de-kildare_001.jpg" alt="Logement St-Ambroise-de Kildare">
356 +
357 + <div class="spacer-010px"></div>
358 + <p class="opensans font-014" style="opacity:0.71;">
359 + Grand appartement 4 1/2 au 2e étage sur l'avenue des Commissaires à Saint-Ambroise de Kildare. Insonorisation et isolation aux dernières normes.
360 + Stationnement, remise, internet, air conditionné, grand balcon privé et aspirateur central inclus. Chat accepté.
361 + <br>
362 + <span class="opensans font-016">
363 + <b>
364 + Disponible maintenant
365 + </b>
366 + </span>
367 + <br>
368 + <span class="opensans font-020">
369 + <b>
370 + 1375$
371 + </b>
372 + </span>
373 + <span class="opensans font-014">
374 + PAR MOIS
375 + </span>
376 + </p>
377 +
378 + <a class="button-slim" href="location-de-logement-joliette-crabtree.html">DEMANDE DE LOCATION&nbsp; &#10095;</a>
379 +
380 + <div class="spacer-005"></div>
381 + </div>
382 +
383 +</div>
384 +
385 +
386 +
387 +<br>
388 +<br>
389 +
390 +
391 +
392 +<div class="columnswithgap-02">
393 +
394 + <div class="">
395 + <p class="opensans font-026 font-hover-01">
396 +
397 + <b>
398 + 3 1/2, rue Frigon à Shawinigan
399 + </b>
400 +
401 + </p>
402 +
403 + <img class="imagehover-02" src="logement-a-louer-mauricie/logement-a-louer-shawinigan_002.jpg" alt="Logement et appartement Shawinigan">
404 +
405 + <div class="spacer-010px"></div>
406 + <p class="opensans font-014" style="opacity:0.71;">
407 + Logement 3 1/2 neuf en demi sous-sol sur la rue Frigon à Shawinigan. Insonorisation et isolation aux dernières normes.
408 + Stationnement, remise extérieure, internet haute vitesse, air conditionné, grand balcon privé et aspirateur central inclus. Chat accepté.
409 + <br>
410 + <span class="opensans font-016">
411 + <b>
412 + Disponible maintenant
413 + </b>
414 + </span>
415 + <br>
416 + <span class="opensans font-020">
417 + <b>
418 + 1235$
419 + </b>
420 + </span>
421 + <span class="opensans font-014">
422 + PAR MOIS
423 + </span>
424 + </p>
425 +
426 + <a class="button-slim" href="location-de-logement-joliette-crabtree.html">DEMANDE DE LOCATION&nbsp; &#10095;</a>
427 +
428 + <div class="spacer-002"></div>
429 + </div>
430 +
431 +
432 + <div></div>
433 +
434 +
435 + <div class="">
436 + <p class="opensans font-026 font-hover-01">
437 +
438 + <b>
439 + 4 1/2, rue Frigon à Shawinigan
440 + </b>
441 +
442 + </p>
443 +
444 + <img class="imagehover-02" src="logement-a-louer-mauricie/logement-a-louer-shawinigan_001.jpg" alt="Logement et appartement Shawinigan">
445 +
446 + <div class="spacer-010px"></div>
447 + <p class="opensans font-014" style="opacity:0.71;">
448 + Logement 4 1/2 neuf en demi sous-sol sur la rue Frigon à Shawinigan. Insonorisation et isolation aux dernières normes.
449 + Stationnement, remise extérieure, internet haute vitesse, air conditionné, grand balcon privé et aspirateur central inclus. Chat accepté.
450 + <br>
451 + <span class="opensans font-016">
452 + <b>
453 + Disponible maintenant
454 + </b>
455 + </span>
456 + <br>
457 + <span class="opensans font-020">
458 + <b>
459 + 1300$
460 + </b>
461 + </span>
462 + <span class="opensans font-014">
463 + PAR MOIS
464 + </span>
465 + </p>
466 + </p>
467 +
468 + <a class="button-slim" href="location-de-logement-joliette-crabtree.html">DEMANDE DE LOCATION&nbsp; &#10095;</a>
469 +
470 + <div class="spacer-005"></div>
471 + </div>
472 +
473 +</div>
474 +
475 +
476 +
477 +
478 +<br>
479 +<br>
480 +
481 +
482 +
483 +
484 +<div class="columnswithgap-02">
485 +
486 + <div class="">
487 + <p class="opensans font-026 font-hover-01">
488 +
489 + <b>
490 + 4 1/2, rue Frigon à Shawinigan
491 + </b>
492 +
493 + </p>
494 +
495 + <img class="imagehover-02" src="logement-a-louer-mauricie/logement-a-louer-shawinigan_002.jpg" alt="Logement et appartement Shawinigan">
496 +
497 + <div class="spacer-010px"></div>
498 + <p class="opensans font-014" style="opacity:0.71;">
499 + Logement 4 1/2 neuf au 1er étage sur la rue Frigon à Shawinigan. Insonorisation et isolation aux dernières normes.
500 + Stationnement, remise extérieure, internet haute vitesse, air conditionné, grand balcon privé et aspirateur central inclus. Chat accepté.
501 + <br>
502 + <span class="opensans font-016">
503 + <b>
504 + Disponible maintenant
505 + </b>
506 + </span>
507 + <br>
508 + <span class="opensans font-020">
509 + <b>
510 + 1325$
511 + </b>
512 + </span>
513 + <span class="opensans font-014">
514 + PAR MOIS
515 + </span>
516 + </p>
517 +
518 + <a class="button-slim" href="location-de-logement-joliette-crabtree.html">DEMANDE DE LOCATION&nbsp; &#10095;</a>
519 +
520 + <div class="spacer-002"></div>
521 + </div>
522 +
523 +
524 + <div></div>
525 +
526 +
527 + <div class="">
528 + <p class="opensans font-026 font-hover-01">
529 +
530 + <b>
531 + 4 1/2, rue Frigon à Shawinigan
532 + </b>
533 +
534 + </p>
535 +
536 + <img class="imagehover-02" src="logement-a-louer-mauricie/logement-a-louer-shawinigan_002.jpg" alt="Logement et appartement Shawinigan">
537 +
538 + <div class="spacer-010px"></div>
539 + <p class="opensans font-014" style="opacity:0.71;">
540 + Logement 4 1/2 neuf au 2e étage sur la rue Frigon à Shawinigan. Insonorisation et isolation aux dernières normes.
541 + Stationnement, remise extérieure, internet haute vitesse, air conditionné, grand balcon privé et aspirateur central inclus. Chat accepté.
542 + <br>
543 + <span class="opensans font-016">
544 + <b>
545 + Disponible maintenant
546 + </b>
547 + </span>
548 + <br>
549 + <span class="opensans font-020">
550 + <b>
551 + 1335$
552 + </b>
553 + </span>
554 + <span class="opensans font-014">
555 + PAR MOIS
556 + </span>
557 + </p>
558 + </p>
559 +
560 + <a class="button-slim" href="location-de-logement-joliette-crabtree.html">DEMANDE DE LOCATION&nbsp; &#10095;</a>
561 +
562 + <div class="spacer-005"></div>
563 + </div>
564 +
565 +</div>
566 +
567 +
568 +
569 +
570 +
571 +<br>
572 +<br>
573 +
574 +
575 +
576 +
577 +
578 +<div class="columnswithgap-02">
579 +
580 + <div class="">
581 + <p class="opensans font-026 font-hover-01">
582 +
583 + <b>
584 + 4 1/2, rue Frigon à Shawinigan
585 + </b>
586 +
587 + </p>
588 +
589 + <img class="imagehover-02" src="logement-a-louer-mauricie/logement-a-louer-shawinigan_002.jpg" alt="Logement et appartement Shawinigan">
590 +
591 + <div class="spacer-010px"></div>
592 + <p class="opensans font-014" style="opacity:0.71;">
593 + Logement 4 1/2 neuf au 3e étage sur la rue Frigon à Shawinigan. Insonorisation et isolation aux dernières normes.
594 + Stationnement, remise extérieure, internet haute vitesse, air conditionné, grand balcon privé et aspirateur central inclus. Chat accepté.
595 + <br>
596 + <span class="opensans font-016">
597 + <b>
598 + Disponible maintenant
599 + </b>
600 + </span>
601 + <br>
602 + <span class="opensans font-020">
603 + <b>
604 + 1350$
605 + </b>
606 + </span>
607 + <span class="opensans font-014">
608 + PAR MOIS
609 + </span>
610 + </p>
611 +
612 + <a class="button-slim" href="location-de-logement-joliette-crabtree.html">DEMANDE DE LOCATION&nbsp; &#10095;</a>
613 +
614 + <div class="spacer-002"></div>
615 + </div>
616 +
617 +
618 + <div></div>
619 +
620 +
621 + <div class="">
622 +
623 +
624 + <div class="spacer-005"></div>
625 + </div>
626 +
627 +</div>
628 +
629 +
630 +
631 +
632 +
633 +<br>
634 +
635 +
636 +
637 +
638 +<br>
639 +<br>
640 +<br>
641 +
642 + <div class="padding-015px aligncenter">
643 + <p class="opensans font-045 blau-03">
644 + ENTREPÔTS DISPONIBLES
645 + </p>
646 + </div>
647 +
648 +
649 +<br>
650 +
651 +
652 +
653 +
654 +<div class="columnswithgap-02">
655 +
656 + <div class="">
657 + <p class="opensans font-026 font-hover-01">
658 + <a href="mini-entrepots/location-mini-entrepot.html" target="_blank">
659 + <b>
660 + Mini-entrepôts, Saint-Thomas (Joliette)
661 + </b>
662 + </a>
663 + </p>
664 +
665 + <a href="mini-entrepots/location-mini-entrepot.html" target="_blank">
666 + <img class="imagehover-02" src="mini-entrepots-st-thomas.jpg" alt="Mini entrepots">
667 + </a>
668 +
669 + <div class="spacer-010px"></div>
670 + <p class="opensans font-014" style="opacity:0.71;">
671 + Mini-entrepôts disponibles pour de la location à court, moyen ou long terme. Plusieurs formats
672 + disponibles avec chauffage ou non, ainsi que des espaces extérieurs. Surveillance par caméra, barrière de sécurité
673 + et terrain clôturé. Disponible depuis avril 2023. RÉSERVEZ VOTRE UNITÉ DÈS MAINTENANT!
674 + <br>
675 + <span class="opensans font-014">
676 + <b>
677 + À partir de
678 + </b>
679 + </span>
680 + <span class="opensans font-020">
681 + <b>
682 + 100$
683 + </b>
684 + </span>
685 + <span class="opensans font-014">
686 + PAR MOIS
687 + </span>
688 + </p>
689 +
690 + <a class="button-slim" href="mini-entrepots/location-mini-entrepot.html" target="_blank">RÉSERVER&nbsp; &#10095;</a>
691 +
692 + <div class="spacer-002"></div>
693 + </div>
694 +
695 + <div></div>
696 +
697 + <div class="">
698 +
699 + <p class="opensans font-026 font-hover-01">
700 + <a href="mini-entrepots/mini-entrepot-saint-come.html" target="_blank">
701 + <b>
702 + Mini-entrepôts, Saint-Côme
703 + </b>
704 + </a>
705 + </p>
706 +
707 + <a href="mini-entrepots/mini-entrepot-saint-come.html" target="_blank">
708 + <img class="imagehover-02" src="mini-entrepots-st-come.jpg" alt="Mini entrepots">
709 + </a>
710 +
711 + <div class="spacer-010px"></div>
712 + <p class="opensans font-014" style="opacity:0.71;">
713 + Mini-entrepôts disponibles pour de la location à court, moyen ou long terme. Plusieurs formats
714 + disponibles avec chauffage ou non, ainsi que des espaces extérieurs. Surveillance par caméra, barrière de sécurité
715 + et terrain clôturé. Pour réserver, veuillez contacter notre gestionnaire au 819&nbsp;692-4522
716 + <br>
717 + <span class="opensans font-014">
718 + <b>
719 + À partir de
720 + </b>
721 + </span>
722 + <span class="opensans font-020">
723 + <b>
724 + 100$
725 + </b>
726 + </span>
727 + <span class="opensans font-014">
728 + PAR MOIS
729 + </span>
730 + </p>
731 +
732 + <a class="button-slim" href="mini-entrepots/mini-entrepot-saint-come.html" target="_blank">RÉSERVER&nbsp; &#10095;</a>
733 +
734 +
735 + <div class="spacer-005"></div>
736 + </div>
737 +
738 +</div>
739 +
740 +
741 +
742 +
743 +
744 +
745 + <!--
746 +
747 + <p class="opensans font-026 font-hover-01">
748 +
749 + <b>
750 + <a href="https://location360.ca" target="_blank">
751 + Mini-entrepôts, Saint-Charles-Borromée
752 + </a>
753 + </b>
754 + </a>
755 + </p>
756 +
757 + <a href="https://location360.ca" target="_blank">
758 + <img class="imagehover-02" src="mini-entrepots-saint-charles-borromee.jpg" alt="Mini entrepots">
759 + </a>
760 +
761 + <div class="spacer-010px"></div>
762 + <p class="opensans font-014" style="opacity:0.71;">
763 + Mini-entrepôts disponibles à Saint-Charles-Borromée pour de la location à court, moyen et long terme. Plusieurs formats non chauffés,
764 + ainsi que des espaces extérieurs. Surveillance par caméra, barrière de sécurité et terrain clôturé. Entreprise professionnelle et respectueuse.
765 + Réservez votre unité dès maintenant! Disponible depuis avril 2023. RÉSERVEZ VOTRE UNITÉ DÈS MAINTENANT!
766 + <br>
767 + <span class="opensans font-014">
768 + <b>
769 + À partir de
770 + </b>
771 + </span>
772 + <span class="opensans font-020">
773 + <b>
774 + 100$
775 + </b>
776 + </span>
777 + <span class="opensans font-014">
778 + PAR MOIS
779 + </span>
780 + </p>
781 +
782 + <a class="button-slim" href="https://location360.ca" target="_blank">RÉSERVER&nbsp; &#10095;</a>
783 +
784 + -->
785 +
786 +
787 +
788 +</div>
789 +
790 +<br>
791 +
792 +
793 +
794 +
795 +
796 +
797 +<div class="spacer-050px"></div>
798 +
799 +
800 +</main>
801 +
802 +
803 +
804 +
805 +<a name="contact"></a>
806 +
807 +
808 +
809 +
810 +<footer>
811 +
812 +<br>
813 +<br>
814 +<br>
815 +
816 +<div class="container-95pct footer-padding">
817 +<div class="spacer-010px"></div>
818 +
819 + <div class="footer-columns-03">
820 +
821 + <div class="aligncenter">
822 + <a class="" href="index.html">
823 + <img class="footer-logo imagehover-02" src="acces-logis-gb_650px_blanc.png" alt="Acces Logis">
824 + </a>
825 + <div class="footer-spacer-002"></div>
826 + <p>
827 + <span class="footer-rbq opensans" style="opacity:0.95;">
828 + <b>
829 + &nbsp;RBQ 5748-0311-01&nbsp;&nbsp;
830 + </b>
831 + </span>
832 + </p>
833 + <br>
834 + <p>
835 + <br>
836 + <span class="opensans footer-email italic">
837 + <a href="mailto:location@acceslogisgb.com">location@acceslogisgb.com</a>
838 + </span>
839 + </p>
840 + <div class="footer-spacer-003"></div>
841 + </div>
842 +
843 +
844 + <div class="aligncenter">
845 + <p>
846 + <span class="opensans footer-title bold">
847 + Sébastien Gélinas
848 + </span>
849 + <br>
850 + <span class="opensans footer-title">
851 + Président
852 + </span>
853 + </p>
854 + <img class="footer-image" src="sebastien-gelinas.jpg" alt="Sebastien Gelinas">
855 + <div class="footer-spacer-002"></div>
856 + <p class="opensans footer-text justify" style="text-align:justify;">
857 + Passionné de projets dans l'immobilier, je suis un promoteur dans ce domaine depuis 10 ans dans la grande
858 + région de Joliette. Je cherche constamment à me dépasser et à offrir aux gens du milieu des logements de
859 + qualité dans un environnement sain, sécuritaire et agréable. L'intégrité, le respect et la rigueur
860 + guident mes actions au quotidien. Je suis fier de mes qualités d'entrepreneur qui font de la compagnie
861 + Accès Logis GB une référence dans le domaine des logements locatifs dans Lanaudière nord.
862 + </p>
863 + <div class="footer-spacer-003"></div>
864 + </div>
865 +
866 +
867 + <div class="aligncenter">
868 + <p>
869 + <span class="opensans footer-title bold">
870 + Charles-Antoine Pilotte
871 + </span>
872 + <br>
873 + <span class="opensans footer-title">
874 + Gestionnaire des immeubles
875 + </span>
876 + </p>
877 + <img class="footer-image" src="charles-antoine-pilotte.jpg" alt="Charles-Antoine Pilotte">
878 + <div class="footer-spacer-002"></div>
879 + <p class="opensans footer-text justify" style="text-align:justify;">
880 + Entrepreneur depuis plusieurs années, j'ai lancé en 2023 une entreprise spécialisée en gestion immobilière : Collab - Solution Immobilière.
881 + Nous travaillons en étroite collaboration avec nos clients afin de les aider à atteindre leurs objectifs et surtout à les libérer de leur
882 + gestion quotidienne. Nous sommes également axés sur la satisfaction des locataires et à leur écoute afin de leur procurer un milieu de vie
883 + agréable dans lequel ils souhaiteront habiter longtemps. De plus, notre équipe polyvalente est également en mesure de s'occuper de pratiquement
884 + tous les travaux manuels d'entretien ou d'urgence, ce qui nous permet d'offrir un service rapide, professionnel et sans délai pour
885 + nos locataires et pour nos clients.
886 + </p>
887 + </div>
888 +
889 +
890 + </div>
891 +
892 +</div>
893 +
894 +
895 +<br>
896 +<br>
897 +<br>
898 +
899 +
900 +<div class="backgroundcolor-01">
901 + <div class="container-1200px columns-02-5050 padding-010px aligncenter">
902 + <div class="verdana-09 fonthover-02">
903 + </div>
904 + <div class="verdana-09 fonthover-02 alignright">
905 +
906 + </div>
907 + </div>
908 +</div>
909 +
910 +
911 +</footer>
912 +
913 +
914 +
915 +<div class="footer-credits">
916 +<div class="footer-credits-spacer"></div>
917 +<a href="https://acceslogisgb.com/">Accès Logis GB inc.</a>
918 +<div class="footer-credits-spacer"></div>
919 +<a href="https://acceslogisgb.com/mini-entrepots/index.html">Location d'entrepôts Joliette</a>
920 +<div class="footer-credits-spacer"></div>
921 +<a href="politique-confidentialite.pdf" target="_blank">Politique de confidentialité</a>
922 +<div class="footer-credits-spacer"></div>
923 +</div>
924 +
925 +
926 +
927 +</body>
928 +</html>
added tests/fixtures/acceslogis_gb/expected.json +159 −0
Large diff (160 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/acceslogis_gb/index.json +9 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/ferrovia/ada8141de33c44ccc840.html +2007 −0
Large diff (2,008 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/ferrovia/c120b357ef6e4896e7ae.html +1996 −0
Large diff (1,997 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/ferrovia/expected.json +327 −0
Large diff (328 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/ferrovia/fd7bf1f70116d8fdd526.html +1995 −0
Large diff (1,996 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/ferrovia/index.json +23 −0
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/fournelle/3201a37934ca2abf2fad.html +811 −0
Large diff (812 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/fournelle/expected.json +61 −0
Large diff (62 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/fournelle/index.json +9 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/habitations_sf/79bc8d812d6cdc35fec4.html +2641 −0
Large diff (2,641 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/habitations_sf/94eb6a0c5b2b7756e31f.html +2631 −0
Large diff (2,631 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/habitations_sf/9bf658dfab06c60e4da7.html +2611 −0
Large diff (2,611 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/habitations_sf/a6b230e06894a8866164.html +2590 −0
Large diff (2,590 lines) is not loaded by default to keep the page fast.Load diff

Diff truncated — file too large.

Menu

Theme