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>
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 ❯</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 ❯</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 ❯</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 ❯</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 ❯</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 ❯</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 ❯</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 ❯</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 ❯</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 ❯</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 ❯</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 ❯</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 ❯</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 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 ❯</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 ❯</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 | + RBQ 5748-0311-01 | |
| 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.