Expansion P2 — Estrie (15 connecteurs, +279 annonces)
Famille Houzez factorisée (dynamic -> gestimmo_estrie, agence_sherbrooke) · uptimo (51) · dynamic (49) · matinale (42, Crawl-Delay 20 respecté) · gestimmo (41) · montagnais (6 campus étudiants) · floria (Livya) + 8 autres. 6 non-connectables documentés (plateforme plogg morte, vitrines sans prix). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
58 changed files +20,001 −0
added
louka/connectors/agence_sherbrooke.py
+34 −0
@@ -0,0 +1,34 @@ | |||||
| 1 | +# ----------------------------------------------------------------------------- | ||||
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | ||||
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 4 | +# connectors/agence_sherbrooke.py : connecteur Agence de location Sherbrooke | ||||
| 5 | +# (agencedelocationsherbrooke.com) — le portail d'annonces du groupe | ||||
| 6 | +# Prestiplex (prestiplex.com n'affiche plus d'inventaire : son sitemap | ||||
| 7 | +# property ne contient qu'une fiche de test). Sherbrooke, East Angus, Magog. | ||||
| 8 | +# Même plateforme WordPress + thème Houzez que Gestion Dynamic : parsing | ||||
| 9 | +# hérité de DynamicConnector. Particularités du site : | ||||
| 10 | +# - la PAGE D'ACCUEIL est la page d'annonces (LIST_PATH = "/") ; | ||||
| 11 | +# - la disponibilité est l'étiquette /label/ de la carte | ||||
| 12 | +# (« Libre maintenant », « Juillet »…) ; | ||||
| 13 | +# - l'adresse complète (Nominatim) est déjà sur la carte | ||||
| 14 | +# (address.item-address) — ville/secteur en sont déduits. | ||||
| 15 | +# ----------------------------------------------------------------------------- | ||||
| 16 | +from __future__ import annotations | ||||
| 17 | + | ||||
| 18 | +from .dynamic import DynamicConnector | ||||
| 19 | + | ||||
| 20 | + | ||||
| 21 | +class AgenceSherbrookeConnector(DynamicConnector): | ||||
| 22 | + source_id = "agence_sherbrooke" | ||||
| 23 | + | ||||
| 24 | + BASE = "https://agencedelocationsherbrooke.com" | ||||
| 25 | + LIST_PATH = "/" | ||||
| 26 | + CITY_DEFAULT = "Sherbrooke" | ||||
| 27 | + max_pages = 6 | ||||
| 28 | + | ||||
| 29 | + # -- crochets par site -------------------------------------------------------- | ||||
| 30 | + def _card_availability(self, card, title: str) -> str: | ||||
| 31 | + """Agence : étiquette /label/ de la carte (« Libre maintenant », | ||||
| 32 | + « Juillet »…) — le lien /status/ porte plutôt le secteur.""" | ||||
| 33 | + label_el = card.select_one("a[href*='/label/']") | ||||
| 34 | + return label_el.get_text(" ", strip=True) if label_el else "" | ||||
added
louka/connectors/alouer_sherbrooke.py
+178 −0
@@ -0,0 +1,178 @@ | |||||
| 1 | +# ----------------------------------------------------------------------------- | ||||
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | ||||
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 4 | +# connectors/alouer_sherbrooke.py : connecteur À Louer Sherbrooke | ||||
| 5 | +# (alouersherbrooke.com) — 100+ logements dans 5 immeubles regroupés du | ||||
| 6 | +# quartier Université (Sherbrooke) ; clientèle étudiants/professionnels/ | ||||
| 7 | +# retraités. WordPress (thème idconception) : la liste est servie par | ||||
| 8 | +# admin-ajax `pagination-load-posts` (CPT appartements, 8 cartes/page, | ||||
| 9 | +# pagination dans la réponse). Cartes : classe `dispo` (À louer, avec date | ||||
| 10 | +# « À partir du … ») ou `loue` (Loué — exclues), grandeur (3½…), quartier, | ||||
| 11 | +# inclusions et photo. Les fiches (via self.detail, cache BD) ajoutent la | ||||
| 12 | +# description complète et la galerie. AUCUN prix ni adresse civique publiés | ||||
| 13 | +# par la source (immeubles regroupés, quartier Université) — champs laissés | ||||
| 14 | +# vides, rien d'inventé. external_id = slug du billet. | ||||
| 15 | +# ----------------------------------------------------------------------------- | ||||
| 16 | +from __future__ import annotations | ||||
| 17 | + | ||||
| 18 | +import hashlib | ||||
| 19 | +import re | ||||
| 20 | + | ||||
| 21 | +from bs4 import BeautifulSoup | ||||
| 22 | + | ||||
| 23 | +from ..schema import Listing, normalize_unit_type | ||||
| 24 | +from .base import BaseConnector | ||||
| 25 | + | ||||
| 26 | +BASE = "https://www.alouersherbrooke.com" | ||||
| 27 | +AJAX_URL = f"{BASE}/wp-admin/admin-ajax.php" | ||||
| 28 | + | ||||
| 29 | +_VARIANT_IMG = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I) | ||||
| 30 | + | ||||
| 31 | + | ||||
| 32 | +class AlouerSherbrookeConnector(BaseConnector): | ||||
| 33 | + source_id = "alouer_sherbrooke" | ||||
| 34 | + request_delay = 0.6 | ||||
| 35 | + max_pages = 12 # garde-fou (7 pages observées) | ||||
| 36 | + max_details = 30 # garde-fou fiches détail (vraies requêtes) | ||||
| 37 | + | ||||
| 38 | + def _ajax_page(self, page: int) -> BeautifulSoup: | ||||
| 39 | + resp = self.session.post( | ||||
| 40 | + AJAX_URL, | ||||
| 41 | + data={"page": str(page), "action": "pagination-load-posts"}, | ||||
| 42 | + timeout=self.timeout) | ||||
| 43 | + resp.raise_for_status() | ||||
| 44 | + return BeautifulSoup(resp.text, "html.parser") | ||||
| 45 | + | ||||
| 46 | + def fetch(self) -> list[Listing]: | ||||
| 47 | + listings: dict[str, Listing] = {} | ||||
| 48 | + for page in range(1, self.max_pages + 1): | ||||
| 49 | + try: | ||||
| 50 | + soup = self._ajax_page(page) | ||||
| 51 | + except Exception: | ||||
| 52 | + break | ||||
| 53 | + cards = soup.select(".appartements__bloc") | ||||
| 54 | + if not cards: | ||||
| 55 | + break | ||||
| 56 | + for card in cards: | ||||
| 57 | + try: | ||||
| 58 | + lst = self._parse_card(card) | ||||
| 59 | + except Exception: | ||||
| 60 | + continue | ||||
| 61 | + if lst and lst.external_id not in listings: | ||||
| 62 | + listings[lst.external_id] = lst | ||||
| 63 | + # borne réelle de pagination (liens numériques de la réponse) | ||||
| 64 | + nums = [int(a.get_text(strip=True)) | ||||
| 65 | + for a in soup.select(".cvf-universal-pagination li a") | ||||
| 66 | + if a.get_text(strip=True).isdigit()] | ||||
| 67 | + if nums and page >= max(nums): | ||||
| 68 | + break | ||||
| 69 | + | ||||
| 70 | + # fiches détail (cache BD) : description + galerie | ||||
| 71 | + self._fetched = 0 | ||||
| 72 | + for lst in listings.values(): | ||||
| 73 | + key = hashlib.sha1( | ||||
| 74 | + f"{lst.title}|{lst.availability}|{lst.unit_type}" | ||||
| 75 | + .encode("utf-8")).hexdigest() | ||||
| 76 | + try: | ||||
| 77 | + payload = self.detail(lst.external_id, key, | ||||
| 78 | + lambda u=lst.url: self._fetch_detail(u)) | ||||
| 79 | + except Exception: | ||||
| 80 | + continue | ||||
| 81 | + if payload.get("description"): | ||||
| 82 | + lst.description = payload["description"] | ||||
| 83 | + if payload.get("images"): | ||||
| 84 | + lst.images = list(dict.fromkeys(payload["images"] + lst.images))[:20] | ||||
| 85 | + return list(listings.values()) | ||||
| 86 | + | ||||
| 87 | + # -- carte AJAX ------------------------------------------------------------- | ||||
| 88 | + def _parse_card(self, card) -> Listing | None: | ||||
| 89 | + classes = card.get("class") or [] | ||||
| 90 | + if "dispo" not in classes: | ||||
| 91 | + return None # « loue » = logement loué, exclu | ||||
| 92 | + link = card.select_one("a[href*='/appartements/']") | ||||
| 93 | + if not link: | ||||
| 94 | + return None | ||||
| 95 | + url = link["href"].split("?")[0] | ||||
| 96 | + m = re.search(r"/appartements/([^/]+)/?$", url.rstrip("/") + "/") | ||||
| 97 | + m = re.search(r"/appartements/([^/]+)", url) | ||||
| 98 | + if not m: | ||||
| 99 | + return None | ||||
| 100 | + slug = m.group(1).strip("/") | ||||
| 101 | + | ||||
| 102 | + title = (link.get("title") or "").strip() | ||||
| 103 | + if not title: | ||||
| 104 | + h2 = card.select_one("h2") | ||||
| 105 | + title = h2.get_text(" ", strip=True) if h2 else slug.replace("-", " ") | ||||
| 106 | + | ||||
| 107 | + # « À louer » + « À partir du 1 septembre 2026 » | ||||
| 108 | + tag = card.select_one(".appartements__dispoTag") | ||||
| 109 | + date = card.select_one(".appartements__date") | ||||
| 110 | + availability = " ".join(x for x in ( | ||||
| 111 | + tag.get_text(" ", strip=True) if tag else "", | ||||
| 112 | + date.get_text(" ", strip=True) if date else "") if x).strip() | ||||
| 113 | + | ||||
| 114 | + gr = card.select_one(".appartement__grandeur") | ||||
| 115 | + unit_type = normalize_unit_type(gr.get_text(strip=True) if gr else "") | ||||
| 116 | + if not re.fullmatch(r"\d½\+?|6½\+|Studio|Loft|Chambre|Maison", | ||||
| 117 | + unit_type or ""): | ||||
| 118 | + unit_type = "" | ||||
| 119 | + | ||||
| 120 | + qu = card.select_one(".appartement__quartier") | ||||
| 121 | + sector = re.sub(r"\s+", " ", | ||||
| 122 | + qu.get_text(" ", strip=True)).strip() if qu else "" | ||||
| 123 | + | ||||
| 124 | + amenities = [] | ||||
| 125 | + for li in card.select("li"): | ||||
| 126 | + t = re.sub(r"\s+", " ", li.get_text(" ", strip=True)) | ||||
| 127 | + if t and t not in amenities: | ||||
| 128 | + amenities.append(t) | ||||
| 129 | + | ||||
| 130 | + img = card.select_one("img[src]") | ||||
| 131 | + images = [] | ||||
| 132 | + if img and str(img.get("src", "")).startswith("http"): | ||||
| 133 | + images.append(_VARIANT_IMG.sub("", img["src"])) | ||||
| 134 | + | ||||
| 135 | + return Listing( | ||||
| 136 | + source=self.source_id, | ||||
| 137 | + external_id=slug, | ||||
| 138 | + url=url, | ||||
| 139 | + title=title, | ||||
| 140 | + address="", # adresse civique non publiée par la source | ||||
| 141 | + sector=sector, # « Université » | ||||
| 142 | + city="Sherbrooke", | ||||
| 143 | + unit_type=unit_type, | ||||
| 144 | + availability=availability, | ||||
| 145 | + amenities=amenities, | ||||
| 146 | + images=images, | ||||
| 147 | + ) | ||||
| 148 | + | ||||
| 149 | + # -- fiche appartement -------------------------------------------------------- | ||||
| 150 | + def _fetch_detail(self, url: str) -> dict: | ||||
| 151 | + if self._fetched >= self.max_details: | ||||
| 152 | + raise RuntimeError("budget de fiches détail atteint") | ||||
| 153 | + self._fetched += 1 | ||||
| 154 | + html = self.get(url).text | ||||
| 155 | + soup = BeautifulSoup(html, "html.parser") | ||||
| 156 | + out: dict = {} | ||||
| 157 | + | ||||
| 158 | + # contenu du billet : listes descriptives (appartement, immeuble, à | ||||
| 159 | + # proximité) — tout en texte brut, le textmine central s'en charge | ||||
| 160 | + main = soup.select_one("article, .single__content, main") or soup | ||||
| 161 | + parts: list[str] = [] | ||||
| 162 | + for el in main.find_all(["p", "li", "h2", "h3"]): | ||||
| 163 | + t = re.sub(r"\s+", " ", el.get_text(" ", strip=True)).strip() | ||||
| 164 | + if t and t not in parts: | ||||
| 165 | + parts.append(t) | ||||
| 166 | + if sum(len(x) for x in parts) > 1500: | ||||
| 167 | + break | ||||
| 168 | + if parts: | ||||
| 169 | + out["description"] = " | ".join(parts)[:1500] | ||||
| 170 | + | ||||
| 171 | + images = [] | ||||
| 172 | + for img in soup.select("img[src*='/wp-content/uploads/']"): | ||||
| 173 | + src = _VARIANT_IMG.sub("", str(img.get("src") or "")) | ||||
| 174 | + if src.startswith("http") and src not in images \ | ||||
| 175 | + and not re.search(r"logo|icon|favicon", src, re.I): | ||||
| 176 | + images.append(src) | ||||
| 177 | + out["images"] = images[:20] | ||||
| 178 | + return out | ||||
added
louka/connectors/ascensio.py
+188 −0
@@ -0,0 +1,188 @@ | |||||
| 1 | +# ----------------------------------------------------------------------------- | ||||
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | ||||
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 4 | +# connectors/ascensio.py : connecteur Groupe Ascensio (groupeascensio.com) | ||||
| 5 | +# Société immobilière — logements neufs à Sherbrooke (Les Nations : | ||||
| 6 | +# arrondissements Jacques-Cartier et Mont-Bellevue) + Pont-Rouge (région de | ||||
| 7 | +# Québec). WordPress : la page /logements-a-louer/ est rendue serveur — | ||||
| 8 | +# grille `.grid-logements .grid-item` (une carte par logement disponible : | ||||
| 9 | +# type + numéro, date de disponibilité, secteur, immeuble, photo, lien | ||||
| 10 | +# /location/<slug>/). Les fiches (via self.detail, cache BD) ajoutent le | ||||
| 11 | +# numéro de référence stable (external_id, ex. BRY-2030134), la mensualité, | ||||
| 12 | +# la description, les pièces et dimensions et la galerie. Seuls les | ||||
| 13 | +# logements affichés (tous « Disponible ») deviennent des annonces. | ||||
| 14 | +# ----------------------------------------------------------------------------- | ||||
| 15 | +from __future__ import annotations | ||||
| 16 | + | ||||
| 17 | +import hashlib | ||||
| 18 | +import re | ||||
| 19 | + | ||||
| 20 | +from bs4 import BeautifulSoup | ||||
| 21 | + | ||||
| 22 | +from ..schema import Listing, normalize_unit_type, parse_price | ||||
| 23 | +from .base import BaseConnector | ||||
| 24 | + | ||||
| 25 | +BASE = "https://groupeascensio.com" | ||||
| 26 | +LIST_URL = f"{BASE}/logements-a-louer/" | ||||
| 27 | + | ||||
| 28 | +_VARIANT_IMG = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I) | ||||
| 29 | + | ||||
| 30 | + | ||||
| 31 | +class AscensioConnector(BaseConnector): | ||||
| 32 | + source_id = "ascensio" | ||||
| 33 | + request_delay = 0.6 | ||||
| 34 | + max_details = 40 # garde-fou fiches détail (vraies requêtes) | ||||
| 35 | + | ||||
| 36 | + def fetch(self) -> list[Listing]: | ||||
| 37 | + html = self.get(LIST_URL).text | ||||
| 38 | + soup = BeautifulSoup(html, "html.parser") | ||||
| 39 | + | ||||
| 40 | + listings: dict[str, Listing] = {} | ||||
| 41 | + for card in soup.select(".grid-logements .grid-item"): | ||||
| 42 | + try: | ||||
| 43 | + lst = self._parse_card(card) | ||||
| 44 | + except Exception: | ||||
| 45 | + continue | ||||
| 46 | + if lst and lst.external_id not in listings: | ||||
| 47 | + listings[lst.external_id] = lst | ||||
| 48 | + | ||||
| 49 | + # fiches détail (cache BD) : référence, mensualité, description, | ||||
| 50 | + # pièces/dimensions, galerie | ||||
| 51 | + self._fetched = 0 | ||||
| 52 | + out: dict[str, Listing] = {} | ||||
| 53 | + for slug, lst in listings.items(): | ||||
| 54 | + key = hashlib.sha1( | ||||
| 55 | + f"{lst.title}|{lst.availability}|{lst.sector}" | ||||
| 56 | + .encode("utf-8")).hexdigest() | ||||
| 57 | + try: | ||||
| 58 | + payload = self.detail(slug, key, | ||||
| 59 | + lambda u=lst.url: self._fetch_detail(u)) | ||||
| 60 | + except Exception: | ||||
| 61 | + payload = {} | ||||
| 62 | + self._apply_detail(lst, payload) | ||||
| 63 | + # référence de gestion stable (BRY-2030134) quand publiée | ||||
| 64 | + ref = (payload.get("reference") or "").strip() | ||||
| 65 | + lst.external_id = ref or slug | ||||
| 66 | + if lst.external_id not in out: | ||||
| 67 | + out[lst.external_id] = lst | ||||
| 68 | + return list(out.values()) | ||||
| 69 | + | ||||
| 70 | + # -- carte de la grille ----------------------------------------------------------- | ||||
| 71 | + def _parse_card(self, card) -> Listing | None: | ||||
| 72 | + link = card.select_one("a[href*='/location/']") | ||||
| 73 | + if not link: | ||||
| 74 | + return None | ||||
| 75 | + url = link["href"] | ||||
| 76 | + m = re.search(r"/location/([^/]+)/?", url) | ||||
| 77 | + if not m: | ||||
| 78 | + return None | ||||
| 79 | + slug = m.group(1) | ||||
| 80 | + | ||||
| 81 | + title_el = card.select_one(".logement-title") | ||||
| 82 | + title = re.sub(r"\s+", " ", | ||||
| 83 | + title_el.get_text(" ", strip=True)).strip() if title_el else slug | ||||
| 84 | + | ||||
| 85 | + # « Disponibilité : 01/08/2026 » (bandeau survol) | ||||
| 86 | + availability = "" | ||||
| 87 | + extra = card.select_one(".extra-infos") | ||||
| 88 | + if extra: | ||||
| 89 | + m2 = re.search(r"Disponibilit[eé]\s*:?\s*([\d/]+)", | ||||
| 90 | + extra.get_text(" ", strip=True)) | ||||
| 91 | + if m2: | ||||
| 92 | + availability = f"Disponible le {m2.group(1)}" | ||||
| 93 | + | ||||
| 94 | + sector_el = card.select_one(".secteur-title") | ||||
| 95 | + sector = "" | ||||
| 96 | + if sector_el: | ||||
| 97 | + sector = re.sub(r"^\s*Secteur\s*:?\s*", "", | ||||
| 98 | + sector_el.get_text(" ", strip=True)).strip(" .") | ||||
| 99 | + | ||||
| 100 | + imm_el = card.select_one(".immeuble-value") | ||||
| 101 | + immeuble = re.sub(r"\s+", " ", | ||||
| 102 | + imm_el.get_text(" ", strip=True)).strip() if imm_el else "" | ||||
| 103 | + | ||||
| 104 | + # ville réelle : Sherbrooke, sauf mention explicite de Pont-Rouge | ||||
| 105 | + city = "Pont-Rouge" if re.search(r"pont-rouge", sector, re.I) else "Sherbrooke" | ||||
| 106 | + | ||||
| 107 | + unit_type = normalize_unit_type(title) | ||||
| 108 | + if not re.fullmatch(r"\d½\+?|6½\+|Studio|Loft|Chambre|Maison", | ||||
| 109 | + unit_type or ""): | ||||
| 110 | + unit_type = "" | ||||
| 111 | + | ||||
| 112 | + img = card.select_one("img[src]") | ||||
| 113 | + images = [] | ||||
| 114 | + if img and str(img.get("src", "")).startswith("http"): | ||||
| 115 | + images.append(_VARIANT_IMG.sub("", img["src"])) | ||||
| 116 | + | ||||
| 117 | + amenities = [f"Immeuble : {immeuble}"] if immeuble else [] | ||||
| 118 | + return Listing( | ||||
| 119 | + source=self.source_id, | ||||
| 120 | + external_id=slug, # remplacé par la référence en aval | ||||
| 121 | + url=url, | ||||
| 122 | + title=f"{title} — {immeuble}" if immeuble else title, | ||||
| 123 | + address=immeuble if re.match(r"^\d", immeuble) else "", | ||||
| 124 | + sector=sector, | ||||
| 125 | + city=city, | ||||
| 126 | + unit_type=unit_type, | ||||
| 127 | + availability=availability, | ||||
| 128 | + amenities=amenities, | ||||
| 129 | + images=images, | ||||
| 130 | + ) | ||||
| 131 | + | ||||
| 132 | + # -- fiche logement --------------------------------------------------------------- | ||||
| 133 | + def _fetch_detail(self, url: str) -> dict: | ||||
| 134 | + if self._fetched >= self.max_details: | ||||
| 135 | + raise RuntimeError("budget de fiches détail atteint") | ||||
| 136 | + self._fetched += 1 | ||||
| 137 | + html = self.get(url).text | ||||
| 138 | + soup = BeautifulSoup(html, "html.parser") | ||||
| 139 | + out: dict = {} | ||||
| 140 | + | ||||
| 141 | + # paires h5 -> valeur : Référence, Mensualité, Disponibilités | ||||
| 142 | + for h in soup.select("h5"): | ||||
| 143 | + lab = h.get_text(" ", strip=True).lower() | ||||
| 144 | + sib = h.find_next_sibling() | ||||
| 145 | + if sib is None: | ||||
| 146 | + continue | ||||
| 147 | + val = re.sub(r"\s+", " ", sib.get_text(" ", strip=True)).strip() | ||||
| 148 | + if "référence" in lab or "reference" in lab: | ||||
| 149 | + out["reference"] = val | ||||
| 150 | + elif "mensualité" in lab or "mensualite" in lab: | ||||
| 151 | + out["price_label"] = val | ||||
| 152 | + elif "disponibilités" in lab or "disponibilites" in lab: | ||||
| 153 | + out["availability"] = val | ||||
| 154 | + elif "pièces et dimensions" in lab and sib.name == "ul": | ||||
| 155 | + out["rooms"] = [re.sub(r"\s+", " ", li.get_text(" ", strip=True)) | ||||
| 156 | + for li in sib.select("li")][:15] | ||||
| 157 | + | ||||
| 158 | + # description : paragraphes longs du corps de la fiche | ||||
| 159 | + paras = [re.sub(r"\s+", " ", p.get_text(" ", strip=True)) | ||||
| 160 | + for p in soup.select("p")] | ||||
| 161 | + longs = [p for p in paras if len(p) > 120] | ||||
| 162 | + if longs: | ||||
| 163 | + out["description"] = " ".join(longs)[:1500] | ||||
| 164 | + | ||||
| 165 | + images: list[str] = [] | ||||
| 166 | + for img in soup.select("img[src*='/wp-content/uploads/']"): | ||||
| 167 | + src = _VARIANT_IMG.sub("", str(img.get("src") or "")) | ||||
| 168 | + if src.startswith("http") and src not in images \ | ||||
| 169 | + and not re.search(r"logo|icon|favicon", src, re.I): | ||||
| 170 | + images.append(src) | ||||
| 171 | + out["images"] = images[:20] | ||||
| 172 | + return out | ||||
| 173 | + | ||||
| 174 | + def _apply_detail(self, lst: Listing, d: dict) -> None: | ||||
| 175 | + if not d: | ||||
| 176 | + return | ||||
| 177 | + if d.get("price_label"): | ||||
| 178 | + lst.price_label = d["price_label"] # « 1425 $ / mois » | ||||
| 179 | + lst.price = parse_price(re.sub(r"(\d)\s(\d{3})", r"\1\2", | ||||
| 180 | + d["price_label"])) | ||||
| 181 | + if d.get("availability"): | ||||
| 182 | + lst.availability = d["availability"] | ||||
| 183 | + if d.get("description"): | ||||
| 184 | + lst.description = d["description"] | ||||
| 185 | + if d.get("rooms"): | ||||
| 186 | + lst.amenities = list(dict.fromkeys(lst.amenities + d["rooms"])) | ||||
| 187 | + if d.get("images"): | ||||
| 188 | + lst.images = list(dict.fromkeys(d["images"] + lst.images))[:20] | ||||
added
louka/connectors/bestlife.py
+189 −0
@@ -0,0 +1,189 @@ | |||||
| 1 | +# ----------------------------------------------------------------------------- | ||||
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | ||||
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 4 | +# connectors/bestlife.py : connecteur Les Gestions Bestlife | ||||
| 5 | +# (lesgestionsbestlife.com) — 500+ portes en gestion à Sherbrooke | ||||
| 6 | +# (Fleurimont, Rock Forest, Mont-Bellevue), East Angus, Richmond. | ||||
| 7 | +# WordPress Divi + WooCommerce : chaque logement est un « produit » | ||||
| 8 | +# (archive /appartements-a-louer-sherbrooke/, cartes li.product avec titre | ||||
| 9 | +# « adresse – type », prix WooCommerce — promo = del/ins — et taxonomie | ||||
| 10 | +# product_cat-<type>). Les fiches produit (via self.detail, cache BD) | ||||
| 11 | +# ajoutent la disponibilité (« Disponible dès maintenant »), les listes | ||||
| 12 | +# Inclusions/Spécifications et la galerie photos. La ville est extraite de | ||||
| 13 | +# la parenthèse du titre (« (East-Angus) », « (Richemond) ») — Sherbrooke | ||||
| 14 | +# par défaut. external_id = slug du produit (stable). | ||||
| 15 | +# ----------------------------------------------------------------------------- | ||||
| 16 | +from __future__ import annotations | ||||
| 17 | + | ||||
| 18 | +import hashlib | ||||
| 19 | +import re | ||||
| 20 | + | ||||
| 21 | +from bs4 import BeautifulSoup | ||||
| 22 | + | ||||
| 23 | +from ..schema import Listing, normalize_unit_type, parse_price | ||||
| 24 | +from .base import BaseConnector | ||||
| 25 | + | ||||
| 26 | +BASE = "https://lesgestionsbestlife.com" | ||||
| 27 | +LIST_URL = f"{BASE}/appartements-a-louer-sherbrooke/" | ||||
| 28 | + | ||||
| 29 | +# « (East-Angus) », « (Richemond) »… -> vraie ville ; sinon Sherbrooke | ||||
| 30 | +_CITY_PARENS = { | ||||
| 31 | + "east-angus": "East Angus", "east angus": "East Angus", | ||||
| 32 | + "richemond": "Richmond", "richmond": "Richmond", | ||||
| 33 | + "windsor": "Windsor", "magog": "Magog", | ||||
| 34 | +} | ||||
| 35 | +_VARIANT_IMG = re.compile(r"[?&](?:resize|fit)=", re.I) | ||||
| 36 | + | ||||
| 37 | + | ||||
| 38 | +def _clean_price_label(label: str) -> str: | ||||
| 39 | + """« 1,150 $ » (virgule de milliers WooCommerce) -> compatible parse_price.""" | ||||
| 40 | + return re.sub(r"(\d),(\d{3})", r"\1\2", label) | ||||
| 41 | + | ||||
| 42 | + | ||||
| 43 | +class BestlifeConnector(BaseConnector): | ||||
| 44 | + source_id = "bestlife" | ||||
| 45 | + request_delay = 0.6 | ||||
| 46 | + max_details = 30 # garde-fou fiches produit (vraies requêtes) | ||||
| 47 | + | ||||
| 48 | + def fetch(self) -> list[Listing]: | ||||
| 49 | + html = self.get(LIST_URL).text | ||||
| 50 | + soup = BeautifulSoup(html, "html.parser") | ||||
| 51 | + | ||||
| 52 | + listings: dict[str, Listing] = {} | ||||
| 53 | + for card in soup.select("li.product"): | ||||
| 54 | + try: | ||||
| 55 | + lst = self._parse_card(card) | ||||
| 56 | + except Exception: | ||||
| 57 | + continue | ||||
| 58 | + if lst and lst.external_id not in listings: | ||||
| 59 | + listings[lst.external_id] = lst | ||||
| 60 | + | ||||
| 61 | + # fiches produit (cache BD) : dispo, inclusions, spécifications, photos | ||||
| 62 | + self._fetched = 0 | ||||
| 63 | + for lst in listings.values(): | ||||
| 64 | + key = hashlib.sha1( | ||||
| 65 | + f"{lst.title}|{lst.price_label}".encode("utf-8")).hexdigest() | ||||
| 66 | + try: | ||||
| 67 | + payload = self.detail(lst.external_id, key, | ||||
| 68 | + lambda u=lst.url: self._fetch_detail(u)) | ||||
| 69 | + except Exception: | ||||
| 70 | + continue | ||||
| 71 | + self._apply_detail(lst, payload) | ||||
| 72 | + return list(listings.values()) | ||||
| 73 | + | ||||
| 74 | + # -- carte produit --------------------------------------------------------------- | ||||
| 75 | + def _parse_card(self, card) -> Listing | None: | ||||
| 76 | + link = card.select_one("a.woocommerce-loop-product__link[href]") | ||||
| 77 | + title_el = card.select_one("h2") | ||||
| 78 | + if not (link and title_el): | ||||
| 79 | + return None | ||||
| 80 | + url = link["href"] | ||||
| 81 | + m = re.search(r"/produit/([^/]+)/?", url) | ||||
| 82 | + if not m: | ||||
| 83 | + return None | ||||
| 84 | + slug = m.group(1) | ||||
| 85 | + title = re.sub(r"\s+", " ", title_el.get_text(" ", strip=True)).strip() | ||||
| 86 | + | ||||
| 87 | + # « 1082 Sainte-Thérèse – 4 1/2 » -> adresse + type | ||||
| 88 | + addr_part = re.split(r"\s*[–—-]\s*(?=\d\s*1/2|Loft|Studio|Chambre)", | ||||
| 89 | + title, maxsplit=1, flags=re.I)[0].strip() | ||||
| 90 | + unit_type = normalize_unit_type(title) | ||||
| 91 | + if not re.fullmatch(r"\d½\+?|6½\+|Studio|Loft|Chambre|Maison", | ||||
| 92 | + unit_type or ""): | ||||
| 93 | + unit_type = "" | ||||
| 94 | + | ||||
| 95 | + # ville depuis la parenthèse du titre (sinon Sherbrooke) | ||||
| 96 | + city = "Sherbrooke" | ||||
| 97 | + pm = re.search(r"\(([^)]+)\)", title) | ||||
| 98 | + if pm: | ||||
| 99 | + key = pm.group(1).strip().lower() | ||||
| 100 | + if key in _CITY_PARENS: | ||||
| 101 | + city = _CITY_PARENS[key] | ||||
| 102 | + addr_part = re.sub(r"\s*\([^)]+\)", "", addr_part).strip() | ||||
| 103 | + | ||||
| 104 | + # prix WooCommerce : promo = <del>régulier</del> <ins>courant</ins> | ||||
| 105 | + price = None | ||||
| 106 | + price_label = "" | ||||
| 107 | + price_el = card.select_one("span.price") | ||||
| 108 | + if price_el: | ||||
| 109 | + price_label = re.sub(r"\s+", " ", | ||||
| 110 | + price_el.get_text(" ", strip=True)).strip() | ||||
| 111 | + ins = price_el.select_one("ins .woocommerce-Price-amount") | ||||
| 112 | + amount = ins or price_el.select_one(".woocommerce-Price-amount") | ||||
| 113 | + if amount: | ||||
| 114 | + price = parse_price(_clean_price_label( | ||||
| 115 | + amount.get_text(" ", strip=True))) | ||||
| 116 | + | ||||
| 117 | + img = card.select_one("img[src]") | ||||
| 118 | + images = [] | ||||
| 119 | + if img: | ||||
| 120 | + src = (img.get("data-orig-file") or img["src"]).strip() | ||||
| 121 | + if src.startswith("http"): | ||||
| 122 | + images.append(src) | ||||
| 123 | + | ||||
| 124 | + return Listing( | ||||
| 125 | + source=self.source_id, | ||||
| 126 | + external_id=slug, | ||||
| 127 | + url=url, | ||||
| 128 | + title=title, | ||||
| 129 | + address=addr_part, | ||||
| 130 | + sector="", # non publié sur la carte | ||||
| 131 | + city=city, | ||||
| 132 | + unit_type=unit_type, | ||||
| 133 | + price=price, | ||||
| 134 | + price_label=price_label, | ||||
| 135 | + availability="", # complété par la fiche produit | ||||
| 136 | + images=images, | ||||
| 137 | + ) | ||||
| 138 | + | ||||
| 139 | + # -- fiche produit ------------------------------------------------------------ | ||||
| 140 | + def _fetch_detail(self, url: str) -> dict: | ||||
| 141 | + if self._fetched >= self.max_details: | ||||
| 142 | + raise RuntimeError("budget de fiches détail atteint") | ||||
| 143 | + self._fetched += 1 | ||||
| 144 | + html = self.get(url).text | ||||
| 145 | + soup = BeautifulSoup(html, "html.parser") | ||||
| 146 | + out: dict = {} | ||||
| 147 | + | ||||
| 148 | + # « Disponible dès maintenant » / « Disponible le 1er septembre » — | ||||
| 149 | + # ligne en emphase de la fiche (jamais les messages techniques du thème) | ||||
| 150 | + for el in soup.find_all(["em", "strong", "p", "h3"]): | ||||
| 151 | + t = re.sub(r"\s+", " ", el.get_text(" ", strip=True)).strip() | ||||
| 152 | + if re.match(r"^Disponible\b", t) and len(t) <= 80: | ||||
| 153 | + out["availability"] = t | ||||
| 154 | + break | ||||
| 155 | + | ||||
| 156 | + # listes Inclusions / Spécifications (commodités affichées) | ||||
| 157 | + amenities: list[str] = [] | ||||
| 158 | + for h in soup.find_all(["h3", "h4"]): | ||||
| 159 | + t = h.get_text(" ", strip=True) | ||||
| 160 | + if t in ("Inclusions", "Spécifications"): | ||||
| 161 | + ul = h.find_next("ul") | ||||
| 162 | + if ul: | ||||
| 163 | + for li in ul.select("li"): | ||||
| 164 | + item = re.sub(r"\s+", " ", li.get_text(" ", strip=True)) | ||||
| 165 | + if item and item not in amenities: | ||||
| 166 | + amenities.append(item) | ||||
| 167 | + out["amenities"] = amenities[:25] | ||||
| 168 | + | ||||
| 169 | + # galerie photos (pleine taille i0.wp.com sans resize) | ||||
| 170 | + images: list[str] = [] | ||||
| 171 | + for img in soup.select(".woocommerce-product-gallery img[src], " | ||||
| 172 | + ".et_pb_gallery img[src]"): | ||||
| 173 | + src = (img.get("data-orig-file") or img.get("src") or "").strip() | ||||
| 174 | + src = src.split("?")[0] if _VARIANT_IMG.search(src) else src | ||||
| 175 | + if src.startswith("http") and src not in images: | ||||
| 176 | + images.append(src) | ||||
| 177 | + out["images"] = images[:20] | ||||
| 178 | + return out | ||||
| 179 | + | ||||
| 180 | + def _apply_detail(self, lst: Listing, d: dict) -> None: | ||||
| 181 | + if not d: | ||||
| 182 | + return | ||||
| 183 | + if d.get("availability"): | ||||
| 184 | + lst.availability = d["availability"] | ||||
| 185 | + if d.get("amenities"): | ||||
| 186 | + lst.amenities = list(dict.fromkeys(lst.amenities + d["amenities"])) | ||||
| 187 | + if d.get("images"): | ||||
| 188 | + merged = list(dict.fromkeys(d["images"] + lst.images)) | ||||
| 189 | + lst.images = merged[:20] | ||||
added
louka/connectors/dynamic.py
+316 −0
@@ -0,0 +1,316 @@ | |||||
| 1 | +# ----------------------------------------------------------------------------- | ||||
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | ||||
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 4 | +# connectors/dynamic.py : connecteur Gestion immobilière Dynamic | ||||
| 5 | +# (lesgestionsdynamic.com) — apparts à Sherbrooke, Magog, Granby, East Angus. | ||||
| 6 | +# WordPress + thème immobilier Houzez, même famille que gimcote.py : archive | ||||
| 7 | +# /a-louer/ paginée (cartes div.item-listing-wrap avec prix, galerie | ||||
| 8 | +# data-images, data-listid). Particularité locale : pas d'adresse ni de | ||||
| 9 | +# statut sur la carte — la disponibilité est un suffixe du titre | ||||
| 10 | +# (« IMMÉDIATEMENT », « JUILLET », « 1er AOÛT »…) et l'adresse/ville/GPS | ||||
| 11 | +# viennent de la fiche détail (bloc #property-address-wrap, carte Houzez). | ||||
| 12 | +# Sert de CLASSE DE BASE à la famille Houzez de l'Estrie : Gestimmo Estrie | ||||
| 13 | +# (gestimmo_estrie.py) et Agence de location Sherbrooke / groupe Prestiplex | ||||
| 14 | +# (agence_sherbrooke.py) n'en redéfinissent que les constantes et les | ||||
| 15 | +# crochets carte (statut/étiquette/adresse). | ||||
| 16 | +# ----------------------------------------------------------------------------- | ||||
| 17 | +from __future__ import annotations | ||||
| 18 | + | ||||
| 19 | +import hashlib | ||||
| 20 | +import html as htmllib | ||||
| 21 | +import json | ||||
| 22 | +import re | ||||
| 23 | + | ||||
| 24 | +from bs4 import BeautifulSoup | ||||
| 25 | + | ||||
| 26 | +from ..schema import (Listing, normalize_unit_type, parse_price, | ||||
| 27 | + strip_accents) | ||||
| 28 | +from .base import BaseConnector | ||||
| 29 | + | ||||
| 30 | +# variantes redimensionnées WordPress (-584x438.jpg) -> pleine taille | ||||
| 31 | +_SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I) | ||||
| 32 | +_MAP_LATLNG_RE = re.compile( | ||||
| 33 | + r'"lat"\s*:\s*"?(-?\d+\.\d+)"?\s*,\s*"lng"\s*:\s*"?(-?\d+\.\d+)"?') | ||||
| 34 | + | ||||
| 35 | +# villes réelles desservies par la famille (jamais devinées : on ne retient la | ||||
| 36 | +# ville que si elle apparaît telle quelle dans l'adresse ou le bloc « Ville ») | ||||
| 37 | +_KNOWN_CITIES = [ | ||||
| 38 | + "Sherbrooke", "Magog", "Granby", "East Angus", "Orford", "Waterville", | ||||
| 39 | + "Windsor", "Coaticook", "Ascot Corner", "Lennoxville", "Richmond", | ||||
| 40 | + "Cookshire-Eaton", "Bromptonville", "Cowansville", "Bromont", "Valcourt", | ||||
| 41 | +] | ||||
| 42 | + | ||||
| 43 | +# suffixe de disponibilité dans les titres Dynamic : « IMMÉDIATEMENT », | ||||
| 44 | +# « JUILLET », « 1er AOÛT », « LOUÉ »… | ||||
| 45 | +_AVAIL_TITLE_RE = re.compile( | ||||
| 46 | + r"(imm[eé]diatement|d[èe]s maintenant|lou[ée]|" | ||||
| 47 | + r"(?:1er\s+|15\s+)?(?:janvier|f[ée]vrier|mars|avril|mai|juin|juillet|" | ||||
| 48 | + r"ao[ûu]t|septembre|octobre|novembre|d[ée]cembre)(?:\s+20\d\d)?)\s*$", | ||||
| 49 | + re.I) | ||||
| 50 | + | ||||
| 51 | + | ||||
| 52 | +def _clean_price_label(label: str) -> str: | ||||
| 53 | + """'1,450$/mois' ou '1 450 $/mois' -> compatible parse_price.""" | ||||
| 54 | + return re.sub(r"(\d)[,\s](\d{3})", r"\1\2", label) | ||||
| 55 | + | ||||
| 56 | + | ||||
| 57 | +def _city_from_parts(parts: list[str]) -> tuple[str, str]: | ||||
| 58 | + """(ville, secteur) depuis les segments Nominatim de address.item-address : | ||||
| 59 | + « 89, Rue des Pins, East Angus, Le Haut-Saint-François, … » — la ville est | ||||
| 60 | + le segment qui correspond à une ville connue, le secteur le segment qui la | ||||
| 61 | + précède (micro-quartier) quand il ne fait pas partie de l'adresse civique.""" | ||||
| 62 | + for i, p in enumerate(parts): | ||||
| 63 | + for city in _KNOWN_CITIES: | ||||
| 64 | + if strip_accents(p.strip().lower()) == strip_accents(city.lower()): | ||||
| 65 | + sector = "" | ||||
| 66 | + if i >= 3: # [n° civique, rue, quartier, ville, ...] | ||||
| 67 | + sector = parts[i - 1].strip() | ||||
| 68 | + return city, sector | ||||
| 69 | + return "", "" | ||||
| 70 | + | ||||
| 71 | + | ||||
| 72 | +class DynamicConnector(BaseConnector): | ||||
| 73 | + source_id = "dynamic" | ||||
| 74 | + request_delay = 0.6 | ||||
| 75 | + max_pages = 12 # garde-fou de pagination | ||||
| 76 | + max_details = 80 # garde-fou fiches détail (vraies requêtes) | ||||
| 77 | + | ||||
| 78 | + BASE = "https://lesgestionsdynamic.com" | ||||
| 79 | + LIST_PATH = "/a-louer/" | ||||
| 80 | + CITY_DEFAULT = "Sherbrooke" # repli si la fiche ne précise pas la ville | ||||
| 81 | + | ||||
| 82 | + def fetch(self) -> list[Listing]: | ||||
| 83 | + listings: dict[str, Listing] = {} | ||||
| 84 | + for page in range(1, self.max_pages + 1): | ||||
| 85 | + url = (f"{self.BASE}{self.LIST_PATH}" if page == 1 | ||||
| 86 | + else f"{self.BASE}{self.LIST_PATH}page/{page}/") | ||||
| 87 | + try: | ||||
| 88 | + html = self.get(url).text | ||||
| 89 | + except Exception: | ||||
| 90 | + break | ||||
| 91 | + soup = BeautifulSoup(html, "html.parser") | ||||
| 92 | + cards = soup.select("div.item-listing-wrap") | ||||
| 93 | + if not cards: | ||||
| 94 | + break | ||||
| 95 | + for card in cards: | ||||
| 96 | + try: | ||||
| 97 | + self._parse_card(card, listings) | ||||
| 98 | + except Exception: | ||||
| 99 | + continue | ||||
| 100 | + | ||||
| 101 | + # fiches détail (cache BD) : adresse/ville/GPS, description, | ||||
| 102 | + # caractéristiques, type d'unité (aperçu Houzez), galerie complète | ||||
| 103 | + self._fetched = 0 | ||||
| 104 | + for lst in listings.values(): | ||||
| 105 | + card_key = hashlib.sha1( | ||||
| 106 | + f"{lst.title}|{lst.price_label}|{lst.availability}" | ||||
| 107 | + .encode("utf-8")).hexdigest() | ||||
| 108 | + try: | ||||
| 109 | + payload = self.detail(lst.external_id, card_key, | ||||
| 110 | + lambda u=lst.url: self._fetch_detail(u)) | ||||
| 111 | + except Exception: | ||||
| 112 | + continue | ||||
| 113 | + self._apply_detail(lst, payload) | ||||
| 114 | + return list(listings.values()) | ||||
| 115 | + | ||||
| 116 | + # -- crochets par site -------------------------------------------------------- | ||||
| 117 | + def _card_availability(self, card, title: str) -> str: | ||||
| 118 | + """Dynamic : la disponibilité est un suffixe du titre de l'annonce.""" | ||||
| 119 | + m = _AVAIL_TITLE_RE.search(title) | ||||
| 120 | + return m.group(1).strip() if m else "" | ||||
| 121 | + | ||||
| 122 | + # -- carte Houzez --------------------------------------------------------------- | ||||
| 123 | + def _parse_card(self, card, listings: dict[str, Listing]) -> None: | ||||
| 124 | + link = card.select_one("h2.item-title a[href]") | ||||
| 125 | + if not link: | ||||
| 126 | + return | ||||
| 127 | + url = link["href"] | ||||
| 128 | + title = re.sub(r"\s+", " ", link.get_text(" ", strip=True)).strip() | ||||
| 129 | + m = re.search(r"/(?:property|appartement)/([^/]+)/?", url) | ||||
| 130 | + slug = m.group(1) if m else "" | ||||
| 131 | + listid_el = card.select_one("[data-listid]") | ||||
| 132 | + ext_id = (listid_el.get("data-listid") if listid_el else "") or slug | ||||
| 133 | + if not ext_id or str(ext_id) in listings: | ||||
| 134 | + return | ||||
| 135 | + | ||||
| 136 | + # exclusions : logements loués + espaces non résidentiels | ||||
| 137 | + if re.search(r"\blou[ée]s?\b", title, re.I): | ||||
| 138 | + return | ||||
| 139 | + if re.search(r"stationnement|commercial|rangement|garage|entrep[oô]t", | ||||
| 140 | + title, re.I): | ||||
| 141 | + return | ||||
| 142 | + availability = self._card_availability(card, title) | ||||
| 143 | + if re.search(r"lou[ée]", availability, re.I): | ||||
| 144 | + return | ||||
| 145 | + | ||||
| 146 | + price_el = card.select_one("li.item-price") | ||||
| 147 | + price_label = price_el.get_text(" ", strip=True) if price_el else "" | ||||
| 148 | + | ||||
| 149 | + # adresse Nominatim de la carte (présente chez Agence, absente chez | ||||
| 150 | + # Dynamic/Gestimmo — la fiche détail prendra le relais) | ||||
| 151 | + address = sector = "" | ||||
| 152 | + city = self.CITY_DEFAULT | ||||
| 153 | + addr_el = card.select_one("address.item-address") | ||||
| 154 | + if addr_el: | ||||
| 155 | + full = addr_el.get_text(" ", strip=True) | ||||
| 156 | + parts = [p.strip() for p in full.split(",") if p.strip()] | ||||
| 157 | + address = ", ".join(parts[:2]) if len(parts) >= 2 else full | ||||
| 158 | + c, s = _city_from_parts(parts) | ||||
| 159 | + if c: | ||||
| 160 | + city, sector = c, s | ||||
| 161 | + | ||||
| 162 | + # commodités des cartes Houzez : chambres / salles de bain / pi² | ||||
| 163 | + amenities: list[str] = [] | ||||
| 164 | + for li in card.select("ul.item-amenities li"): | ||||
| 165 | + t = re.sub(r"\s+", " ", li.get_text(" ", strip=True)) | ||||
| 166 | + t = t.replace("Beds:", "Chambres :").replace("Bath:", "Salle(s) de bain :") | ||||
| 167 | + if t and t not in amenities: | ||||
| 168 | + amenities.append(t) | ||||
| 169 | + | ||||
| 170 | + # galerie : attribut data-images (JSON, URLs redimensionnées) | ||||
| 171 | + images: list[str] = [] | ||||
| 172 | + raw = card.get("data-images") or "" | ||||
| 173 | + if raw: | ||||
| 174 | + try: | ||||
| 175 | + urls = json.loads(htmllib.unescape(raw)) | ||||
| 176 | + except Exception: | ||||
| 177 | + urls = re.findall(r"https?:[^\"',\\]+", htmllib.unescape(raw)) | ||||
| 178 | + for u in urls: | ||||
| 179 | + if isinstance(u, dict): # variante Houzez : objets {url: …} | ||||
| 180 | + u = u.get("url") or u.get("src") or u.get("image") or "" | ||||
| 181 | + if not isinstance(u, str): | ||||
| 182 | + continue | ||||
| 183 | + u = u.replace("\\/", "/").strip() | ||||
| 184 | + if u.startswith("http"): | ||||
| 185 | + u = _SIZE_SUFFIX.sub("", u) | ||||
| 186 | + if u not in images: | ||||
| 187 | + images.append(u) | ||||
| 188 | + if not images: | ||||
| 189 | + thumb = card.select_one("img.wp-post-image[src]") | ||||
| 190 | + if thumb: | ||||
| 191 | + images = [_SIZE_SUFFIX.sub("", thumb["src"])] | ||||
| 192 | + | ||||
| 193 | + # type d'unité depuis le titre, seulement si le motif est net | ||||
| 194 | + # (normalize_unit_type retourne le texte brut quand rien ne matche) | ||||
| 195 | + unit_type = normalize_unit_type(title) | ||||
| 196 | + if not re.fullmatch(r"\d½\+?|6½\+|Studio|Loft|Chambre|Maison", | ||||
| 197 | + unit_type or ""): | ||||
| 198 | + unit_type = "" | ||||
| 199 | + | ||||
| 200 | + listings[str(ext_id)] = Listing( | ||||
| 201 | + source=self.source_id, | ||||
| 202 | + external_id=str(ext_id), | ||||
| 203 | + url=url, | ||||
| 204 | + title=title, | ||||
| 205 | + address=address, | ||||
| 206 | + sector=sector, | ||||
| 207 | + city=city, | ||||
| 208 | + unit_type=unit_type, | ||||
| 209 | + price=parse_price(_clean_price_label(price_label)), | ||||
| 210 | + price_label=price_label, | ||||
| 211 | + availability=availability, | ||||
| 212 | + amenities=amenities, | ||||
| 213 | + images=images[:30], | ||||
| 214 | + ) | ||||
| 215 | + | ||||
| 216 | + # -- fiche détail (Houzez) ----------------------------------------------------- | ||||
| 217 | + def _fetch_detail(self, url: str) -> dict: | ||||
| 218 | + """Adresse structurée (#property-address-wrap), description, | ||||
| 219 | + caractéristiques, type d'unité (aperçu) et GPS (carte Houzez).""" | ||||
| 220 | + if self._fetched >= self.max_details: | ||||
| 221 | + raise RuntimeError("budget de fiches détail atteint") | ||||
| 222 | + self._fetched += 1 | ||||
| 223 | + html = self.get(url).text | ||||
| 224 | + soup = BeautifulSoup(html, "html.parser") | ||||
| 225 | + out: dict = {} | ||||
| 226 | + | ||||
| 227 | + desc_el = soup.select_one("#property-description-wrap") | ||||
| 228 | + if desc_el: | ||||
| 229 | + txt = desc_el.get_text("\n", strip=True) | ||||
| 230 | + txt = re.sub(r"^Description\n", "", txt) | ||||
| 231 | + out["description"] = re.sub(r"[ \t]+", " ", txt).strip()[:1500] | ||||
| 232 | + | ||||
| 233 | + out["amenities"] = [a.get_text(" ", strip=True) | ||||
| 234 | + for a in soup.select("#property-features-wrap li") | ||||
| 235 | + if a.get_text(strip=True)][:25] | ||||
| 236 | + | ||||
| 237 | + # bloc adresse : « Adresse | 375 rue terrill | Ville | | ||||
| 238 | + # Fleurimont (Sherbrooke) | Code postal | J1E 3S7 » | ||||
| 239 | + for li in soup.select("#property-address-wrap li"): | ||||
| 240 | + st, sp = li.find("strong"), li.find("span") | ||||
| 241 | + if not (st and sp): | ||||
| 242 | + continue | ||||
| 243 | + lab = strip_accents(st.get_text(" ", strip=True).lower()) | ||||
| 244 | + val = sp.get_text(" ", strip=True) | ||||
| 245 | + if lab.startswith("adresse"): | ||||
| 246 | + out["address"] = val | ||||
| 247 | + elif lab.startswith("ville"): | ||||
| 248 | + out["city_raw"] = val | ||||
| 249 | + elif "postal" in lab: | ||||
| 250 | + out["postal"] = val | ||||
| 251 | + | ||||
| 252 | + # adresse Nominatim complète (repli ville/secteur) | ||||
| 253 | + addr_el = soup.select_one("address.item-address") | ||||
| 254 | + if addr_el: | ||||
| 255 | + out["item_address"] = addr_el.get_text(" ", strip=True) | ||||
| 256 | + | ||||
| 257 | + # type d'unité : aperçu Houzez (« Appartement 4 1/2 ») — exiger un | ||||
| 258 | + # motif net (« … 4 1/2 », « Studio »…), jamais le mot « chambres » du | ||||
| 259 | + # compteur de pièces | ||||
| 260 | + ov = soup.select_one(".property-overview-wrap, #property-overview-wrap") | ||||
| 261 | + if ov: | ||||
| 262 | + m = re.search(r"(\d\s*(?:1/2|½)|\bstudio\b|\bloft\b|\bmaison\b)", | ||||
| 263 | + ov.get_text(" ", strip=True), re.I) | ||||
| 264 | + if m: | ||||
| 265 | + out["type_raw"] = m.group(1) | ||||
| 266 | + | ||||
| 267 | + m = _MAP_LATLNG_RE.search(html) | ||||
| 268 | + if m: | ||||
| 269 | + out["lat"], out["lng"] = float(m.group(1)), float(m.group(2)) | ||||
| 270 | + return out | ||||
| 271 | + | ||||
| 272 | + def _apply_detail(self, lst: Listing, d: dict) -> None: | ||||
| 273 | + """Reporte le payload (frais ou cache BD) sur l'annonce.""" | ||||
| 274 | + if not d: | ||||
| 275 | + return | ||||
| 276 | + if d.get("description"): | ||||
| 277 | + lst.description = d["description"] | ||||
| 278 | + if d.get("amenities"): | ||||
| 279 | + lst.amenities = list(dict.fromkeys(lst.amenities + d["amenities"])) | ||||
| 280 | + | ||||
| 281 | + # adresse structurée de la fiche ; « Ville » au format | ||||
| 282 | + # « Fleurimont (Sherbrooke) » (secteur (ville)) ou « Magog » | ||||
| 283 | + if d.get("address") and not lst.address: | ||||
| 284 | + lst.address = d["address"] | ||||
| 285 | + city_raw = (d.get("city_raw") or "").strip() | ||||
| 286 | + if city_raw: | ||||
| 287 | + m = re.match(r"^(.*?)\s*\((.+)\)\s*$", city_raw) | ||||
| 288 | + inner = (m.group(2).strip() if m else city_raw) | ||||
| 289 | + outer = (m.group(1).strip() if m else "") | ||||
| 290 | + c, _ = _city_from_parts([inner]) | ||||
| 291 | + if c: # « Fleurimont (Sherbrooke) » | ||||
| 292 | + lst.city = c | ||||
| 293 | + if outer and not lst.sector: | ||||
| 294 | + lst.sector = outer | ||||
| 295 | + else: | ||||
| 296 | + c2, _ = _city_from_parts([city_raw]) | ||||
| 297 | + if c2: # « Magog » | ||||
| 298 | + lst.city = c2 | ||||
| 299 | + elif not lst.sector: # « Nord », « Centre-ville »… | ||||
| 300 | + lst.sector = city_raw | ||||
| 301 | + elif d.get("item_address"): | ||||
| 302 | + parts = [p.strip() for p in d["item_address"].split(",") if p.strip()] | ||||
| 303 | + c, s = _city_from_parts(parts) | ||||
| 304 | + if c: | ||||
| 305 | + lst.city = c | ||||
| 306 | + if s and not lst.sector: | ||||
| 307 | + lst.sector = s | ||||
| 308 | + if not lst.address and d.get("item_address"): | ||||
| 309 | + parts = [p.strip() for p in d["item_address"].split(",") if p.strip()] | ||||
| 310 | + if len(parts) >= 2: | ||||
| 311 | + lst.address = ", ".join(parts[:2]) | ||||
| 312 | + | ||||
| 313 | + if not lst.unit_type and d.get("type_raw"): | ||||
| 314 | + lst.unit_type = normalize_unit_type(d["type_raw"]) | ||||
| 315 | + if d.get("lat") is not None and d.get("lng") is not None: | ||||
| 316 | + lst.lat, lst.lng = d["lat"], d["lng"] | ||||
added
louka/connectors/floria.py
+139 −0
@@ -0,0 +1,139 @@ | |||||
| 1 | +# ----------------------------------------------------------------------------- | ||||
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | ||||
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 4 | +# connectors/floria.py : connecteur Gestion Floria (gestionfloria.ca) | ||||
| 5 | +# Condos locatifs neufs à Sherbrooke, Magog (Mont-Orford) et Coaticook. | ||||
| 6 | +# Site WordPress (Avada) : chaque page /immeuble/<slug>/ embarque le module | ||||
| 7 | +# de plans interactifs Livya (app.livya.com, client « gestion-floria », un | ||||
| 8 | +# projet/entité par immeuble). Même patron que somex_saintnicolas/louis14 : | ||||
| 9 | +# la page Next.js du module est rendue serveur et son flux RSC contient | ||||
| 10 | +# l'inventaire JSON complet (numéro, statut, loyer, pièces, superficie, | ||||
| 11 | +# adresse, ville, GPS, photos, plans). Découverte des immeubles via la page | ||||
| 12 | +# /secteurs/. Certains immeubles partagent le même projet Livya (jumelés | ||||
| 13 | +# 52/58 Flanc-Sud, 753/765 Rachel-Hébert…) : dédoublonnage par unitId. | ||||
| 14 | +# Seules les unités AVAILABLE deviennent des annonces ; certains projets ne | ||||
| 15 | +# publient pas le loyer (rentalPrice = 0) -> prix absent, rien d'inventé. | ||||
| 16 | +# ----------------------------------------------------------------------------- | ||||
| 17 | +from __future__ import annotations | ||||
| 18 | + | ||||
| 19 | +import re | ||||
| 20 | + | ||||
| 21 | +from ..schema import Listing | ||||
| 22 | +from .base import BaseConnector | ||||
| 23 | +from .somex_saintnicolas import _livya_units, _unit_type | ||||
| 24 | + | ||||
| 25 | +BASE = "https://gestionfloria.ca" | ||||
| 26 | +SECTORS_URL = f"{BASE}/secteurs/" | ||||
| 27 | +LIVYA = "https://app.livya.com" | ||||
| 28 | + | ||||
| 29 | +_IMMEUBLE_RE = re.compile(r"https://gestionfloria\.ca/immeuble/[a-z0-9-]+/") | ||||
| 30 | +_MODULE_RE = re.compile( | ||||
| 31 | + r'<div class="livya-module-container-plans"[^>]*' | ||||
| 32 | + r'data-project="([^"]+)"[^>]*data-entity="([^"]+)"') | ||||
| 33 | +_CLIENT_RE = re.compile(r'data-client="([^"]+)"') | ||||
| 34 | + | ||||
| 35 | + | ||||
| 36 | +class FloriaConnector(BaseConnector): | ||||
| 37 | + source_id = "floria" | ||||
| 38 | + request_delay = 0.7 | ||||
| 39 | + max_buildings = 20 # garde-fou (12 immeubles publiés) | ||||
| 40 | + | ||||
| 41 | + def fetch(self) -> list[Listing]: | ||||
| 42 | + # 1) Découverte : pages immeuble listées sur /secteurs/ | ||||
| 43 | + sec = self.get(SECTORS_URL).text | ||||
| 44 | + pages = sorted(set(_IMMEUBLE_RE.findall(sec))) | ||||
| 45 | + | ||||
| 46 | + listings: dict[str, Listing] = {} | ||||
| 47 | + for page_url in pages[: self.max_buildings]: | ||||
| 48 | + try: | ||||
| 49 | + html = self.get(page_url).text | ||||
| 50 | + except Exception: | ||||
| 51 | + continue | ||||
| 52 | + m = _MODULE_RE.search(html) | ||||
| 53 | + cm = _CLIENT_RE.search(html) | ||||
| 54 | + if not (m and cm): | ||||
| 55 | + continue # immeuble sans module Livya (ex. 2835 Mézy) | ||||
| 56 | + project, entity, client = m.group(1), m.group(2), cm.group(1) | ||||
| 57 | + try: | ||||
| 58 | + livya_html = self.get( | ||||
| 59 | + f"{LIVYA}/fr/{client}/projects/{project}/plans/{entity}" | ||||
| 60 | + f"?noLayout=1").text | ||||
| 61 | + except Exception: | ||||
| 62 | + continue | ||||
| 63 | + for u in _livya_units(livya_html): | ||||
| 64 | + # dédoublonnage : les immeubles jumelés partagent le projet | ||||
| 65 | + uid = str(u.get("unitId") or "") | ||||
| 66 | + if not uid or uid in listings: | ||||
| 67 | + continue | ||||
| 68 | + if u.get("availability") != "AVAILABLE" or not u.get("rental", True): | ||||
| 69 | + continue | ||||
| 70 | + listings[uid] = self._listing(u, page_url) | ||||
| 71 | + return list(listings.values()) | ||||
| 72 | + | ||||
| 73 | + # -- une annonce par unité disponible ------------------------------------------ | ||||
| 74 | + def _listing(self, u: dict, page_url: str) -> Listing: | ||||
| 75 | + num = str(u.get("number") or "").strip() | ||||
| 76 | + price = u.get("rentalPrice") | ||||
| 77 | + price = float(price) if isinstance(price, (int, float)) and price > 0 else None | ||||
| 78 | + area = u.get("unitSize") | ||||
| 79 | + area = float(area) if isinstance(area, (int, float)) and area > 0 else None | ||||
| 80 | + | ||||
| 81 | + street = (u.get("address") or "").strip() | ||||
| 82 | + city = (u.get("city") or "").strip() | ||||
| 83 | + postal = (u.get("postalCode") or "").strip() | ||||
| 84 | + address = ", ".join(x for x in (street, postal) if x) | ||||
| 85 | + | ||||
| 86 | + # description : étage, modèle, pièces, balcon, plan (champs structurés | ||||
| 87 | + # du flux — le textmine central en tire les dérivés) | ||||
| 88 | + desc: list[str] = [] | ||||
| 89 | + floor = str(u.get("floorNumber") or "").strip() | ||||
| 90 | + if floor: | ||||
| 91 | + desc.append(f"Étage {floor}") | ||||
| 92 | + if u.get("typeName"): | ||||
| 93 | + desc.append(f"Modèle {u['typeName']}") | ||||
| 94 | + if u.get("roomsBed"): | ||||
| 95 | + desc.append(f"{u['roomsBed']} chambre(s)") | ||||
| 96 | + if u.get("roomsBath"): | ||||
| 97 | + desc.append(f"{u['roomsBath']} salle(s) de bain") | ||||
| 98 | + if u.get("balconySize"): | ||||
| 99 | + desc.append(f"Balcon de {u['balconySize']} pi²") | ||||
| 100 | + if u.get("floorPlanUrl"): | ||||
| 101 | + desc.append(f"Plan : {u['floorPlanUrl']}") | ||||
| 102 | + | ||||
| 103 | + images = [img.get("fullUrl") for img in (u.get("typeImages") or []) | ||||
| 104 | + if isinstance(img, dict) and img.get("fullUrl")] | ||||
| 105 | + if u.get("floorPlanImageUrl"): | ||||
| 106 | + images.append(u["floorPlanImageUrl"]) | ||||
| 107 | + | ||||
| 108 | + future = u.get("futureAvailability") | ||||
| 109 | + availability = str(future) if future else "Disponible" | ||||
| 110 | + | ||||
| 111 | + details: dict = {} | ||||
| 112 | + if floor.isdigit(): | ||||
| 113 | + details["floor"] = int(floor) | ||||
| 114 | + | ||||
| 115 | + lat, lng = u.get("latitude"), u.get("longitude") | ||||
| 116 | + unit_type = _unit_type(u.get("rooms")) | ||||
| 117 | + title = f"{street} — unité {num}" if street else f"Unité {num}" | ||||
| 118 | + if unit_type: | ||||
| 119 | + title += f" ({unit_type})" | ||||
| 120 | + | ||||
| 121 | + return Listing( | ||||
| 122 | + source=self.source_id, | ||||
| 123 | + external_id=str(u["unitId"]), | ||||
| 124 | + url=page_url, # la page immeuble héberge le sélecteur | ||||
| 125 | + title=title, | ||||
| 126 | + address=address, | ||||
| 127 | + sector="", # secteur non publié unité par unité | ||||
| 128 | + city=city, | ||||
| 129 | + unit_type=unit_type, | ||||
| 130 | + price=price, | ||||
| 131 | + price_label=f"{price:.0f} $ /mois" if price else "", | ||||
| 132 | + availability=availability, | ||||
| 133 | + area_sqft=area, | ||||
| 134 | + description=" | ".join(desc), | ||||
| 135 | + details=details, | ||||
| 136 | + images=images[:12], | ||||
| 137 | + lat=float(lat) if lat else None, | ||||
| 138 | + lng=float(lng) if lng else None, | ||||
| 139 | + ) | ||||
added
louka/connectors/gestimmo_estrie.py
+48 −0
@@ -0,0 +1,48 @@ | |||||
| 1 | +# ----------------------------------------------------------------------------- | ||||
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | ||||
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 4 | +# connectors/gestimmo_estrie.py : connecteur Gestimmo Estrie (gestimmoestrie.com) | ||||
| 5 | +# Gestionnaire multi-immeubles de Sherbrooke et de l'Estrie. Même plateforme | ||||
| 6 | +# WordPress + thème Houzez que Gestion Dynamic : tout le parsing (cartes, | ||||
| 7 | +# pagination /a-louer/page/N/, fiches détail, cache BD) est hérité de | ||||
| 8 | +# DynamicConnector. Particularités du site : | ||||
| 9 | +# - la disponibilité est l'étiquette de statut de la carte | ||||
| 10 | +# (lien /status/ : « Libre maintenant », « Juillet », « Loué »…) ; | ||||
| 11 | +# - les fiches n'ont pas d'aperçu Houzez : le type d'unité vient du titre | ||||
| 12 | +# (« 4 1/2 Rue de la Sainte-Famille ») ou de la ligne « Grandeur : 4 1/2 » | ||||
| 13 | +# de la description. | ||||
| 14 | +# Les logements « Loué » sont exclus (filtre hérité). | ||||
| 15 | +# ----------------------------------------------------------------------------- | ||||
| 16 | +from __future__ import annotations | ||||
| 17 | + | ||||
| 18 | +import re | ||||
| 19 | + | ||||
| 20 | +from .dynamic import DynamicConnector | ||||
| 21 | + | ||||
| 22 | +# « Grandeur : 4 1/2 » / « Grandeur: Studio » dans la description des fiches | ||||
| 23 | +_SIZE_IN_DESC = re.compile(r"grandeur\s*:?\s*(\d\s*1/2|\d\s*½|studio|loft)", re.I) | ||||
| 24 | + | ||||
| 25 | + | ||||
| 26 | +class GestimmoEstrieConnector(DynamicConnector): | ||||
| 27 | + source_id = "gestimmo_estrie" | ||||
| 28 | + | ||||
| 29 | + BASE = "https://gestimmoestrie.com" | ||||
| 30 | + LIST_PATH = "/a-louer/" | ||||
| 31 | + CITY_DEFAULT = "Sherbrooke" | ||||
| 32 | + | ||||
| 33 | + # -- crochets par site -------------------------------------------------------- | ||||
| 34 | + def _card_availability(self, card, title: str) -> str: | ||||
| 35 | + """Gestimmo : étiquette de statut de la carte (« Libre maintenant », | ||||
| 36 | + « Juillet », « Loué »…).""" | ||||
| 37 | + status_el = card.select_one("a[href*='/status/']") | ||||
| 38 | + return status_el.get_text(" ", strip=True) if status_el else "" | ||||
| 39 | + | ||||
| 40 | + def _fetch_detail(self, url: str) -> dict: | ||||
| 41 | + """Fiche Houzez sans bloc aperçu : repli type d'unité sur la ligne | ||||
| 42 | + « Grandeur : … » de la description.""" | ||||
| 43 | + out = super()._fetch_detail(url) | ||||
| 44 | + if not out.get("type_raw") and out.get("description"): | ||||
| 45 | + m = _SIZE_IN_DESC.search(out["description"]) | ||||
| 46 | + if m: | ||||
| 47 | + out["type_raw"] = m.group(1) | ||||
| 48 | + return out | ||||
added
louka/connectors/immeubles_db.py
+133 −0
@@ -0,0 +1,133 @@ | |||||
| 1 | +# ----------------------------------------------------------------------------- | ||||
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | ||||
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 4 | +# connectors/immeubles_db.py : connecteur Les Immeubles D.B. Busque-Massé | ||||
| 5 | +# (immeublesdb.com) — 12 immeubles / ~400 apparts à Sherbrooke, 4 secteurs. | ||||
| 6 | +# Site custom PHP (« pm_editor », derrière Cloudflare — le UA de base.py | ||||
| 7 | +# passe) : 4 pages statiques /logements-a-louer/secteur-{nord,est, | ||||
| 8 | +# centre-ville,ouest}/. Chaque logement offert = une section .container | ||||
| 9 | +# avec une cellule galerie (photos /modules/upload/…) et une cellule texte | ||||
| 10 | +# (h3 = adresse civique, liste <li> = type, prix « … $ par mois », | ||||
| 11 | +# inclusions). Aucun identifiant publié : external_id = empreinte stable | ||||
| 12 | +# (secteur + adresse + ligne de type). Les logements loués sont simplement | ||||
| 13 | +# retirés de la page (aucun marqueur « complet » à filtrer). | ||||
| 14 | +# ----------------------------------------------------------------------------- | ||||
| 15 | +from __future__ import annotations | ||||
| 16 | + | ||||
| 17 | +import hashlib | ||||
| 18 | +import re | ||||
| 19 | + | ||||
| 20 | +from bs4 import BeautifulSoup | ||||
| 21 | + | ||||
| 22 | +from ..schema import Listing, normalize_unit_type, parse_price | ||||
| 23 | +from .base import BaseConnector | ||||
| 24 | + | ||||
| 25 | +BASE = "https://immeublesdb.com" | ||||
| 26 | +SECTORS = { | ||||
| 27 | + "secteur-nord": "Nord", | ||||
| 28 | + "secteur-est": "Est", | ||||
| 29 | + "secteur-centre-ville": "Centre-ville", | ||||
| 30 | + "secteur-ouest": "Ouest", | ||||
| 31 | +} | ||||
| 32 | + | ||||
| 33 | +_PRICE_LI = re.compile(r"\d[\d\s]{0,5}\s?\$\s*par mois", re.I) | ||||
| 34 | + | ||||
| 35 | + | ||||
| 36 | +def _clean_price_label(label: str) -> str: | ||||
| 37 | + """« 1 125$ par mois » (espace de milliers) -> compatible parse_price.""" | ||||
| 38 | + return re.sub(r"(\d)\s(\d{3})", r"\1\2", label) | ||||
| 39 | + | ||||
| 40 | + | ||||
| 41 | +class ImmeublesDbConnector(BaseConnector): | ||||
| 42 | + source_id = "immeubles_db" | ||||
| 43 | + request_delay = 0.6 | ||||
| 44 | + | ||||
| 45 | + def fetch(self) -> list[Listing]: | ||||
| 46 | + listings: dict[str, Listing] = {} | ||||
| 47 | + for slug, sector in SECTORS.items(): | ||||
| 48 | + try: | ||||
| 49 | + html = self.get(f"{BASE}/logements-a-louer/{slug}/").text | ||||
| 50 | + except Exception: | ||||
| 51 | + continue | ||||
| 52 | + soup = BeautifulSoup(html, "html.parser") | ||||
| 53 | + for container in soup.select("div.container"): | ||||
| 54 | + try: | ||||
| 55 | + lst = self._parse_block(container, sector) | ||||
| 56 | + except Exception: | ||||
| 57 | + continue | ||||
| 58 | + if lst and lst.external_id not in listings: | ||||
| 59 | + listings[lst.external_id] = lst | ||||
| 60 | + return list(listings.values()) | ||||
| 61 | + | ||||
| 62 | + # -- une section .container = un logement offert -------------------------------- | ||||
| 63 | + def _parse_block(self, container, sector: str) -> Listing | None: | ||||
| 64 | + h3 = container.select_one(".cell_container h3") | ||||
| 65 | + ul = container.select_one(".cell_container ul") | ||||
| 66 | + if not (h3 and ul): | ||||
| 67 | + return None | ||||
| 68 | + address_full = re.sub(r"\s+", " ", h3.get_text(" ", strip=True)).strip(" ,") | ||||
| 69 | + lis = [re.sub(r"\s+", " ", li.get_text(" ", strip=True)).strip() | ||||
| 70 | + for li in ul.select("li")] | ||||
| 71 | + lis = [t for t in lis if t] | ||||
| 72 | + if not (address_full and lis): | ||||
| 73 | + return None | ||||
| 74 | + | ||||
| 75 | + # « 250, Olivier, Sherbrooke, QC » -> adresse sans la province | ||||
| 76 | + parts = [p.strip() for p in address_full.split(",") if p.strip()] | ||||
| 77 | + address = ", ".join(p for p in parts if p.upper() != "QC") | ||||
| 78 | + city = "Sherbrooke" | ||||
| 79 | + | ||||
| 80 | + type_line = lis[0] | ||||
| 81 | + # « 4 CHAMBRES disponibles à partir de 550 $ » = location à la chambre | ||||
| 82 | + # (normalize_unit_type transformerait « 4 chambres » en 6½+) | ||||
| 83 | + if re.search(r"chambres?\s+disponibles?", type_line, re.I): | ||||
| 84 | + unit_type = "Chambre" | ||||
| 85 | + else: | ||||
| 86 | + unit_type = normalize_unit_type(type_line) | ||||
| 87 | + if not re.fullmatch(r"\d½\+?|6½\+|Studio|Loft|Chambre|Maison", | ||||
| 88 | + unit_type or ""): | ||||
| 89 | + unit_type = "" | ||||
| 90 | + | ||||
| 91 | + # prix : première ligne « … $ par mois » | ||||
| 92 | + price = None | ||||
| 93 | + price_label = "" | ||||
| 94 | + for t in lis: | ||||
| 95 | + if _PRICE_LI.search(t): | ||||
| 96 | + price_label = t | ||||
| 97 | + price = parse_price(_clean_price_label(t)) | ||||
| 98 | + break | ||||
| 99 | + | ||||
| 100 | + # disponibilité : seulement si la source l'écrit (sinon vide) | ||||
| 101 | + availability = "" | ||||
| 102 | + m = re.search(r"libre\s+imm[ée]diatement", " ".join(lis), re.I) | ||||
| 103 | + if m: | ||||
| 104 | + availability = "Libre immédiatement" | ||||
| 105 | + | ||||
| 106 | + # photos de la galerie jumelle (chemins relatifs /modules/upload/…) | ||||
| 107 | + images: list[str] = [] | ||||
| 108 | + for img in container.select(".galerie_img_block a[href]"): | ||||
| 109 | + href = (img.get("href") or "").strip() | ||||
| 110 | + if href.startswith("/"): | ||||
| 111 | + href = BASE + href | ||||
| 112 | + if href.startswith("http") and href not in images: | ||||
| 113 | + images.append(href) | ||||
| 114 | + | ||||
| 115 | + ext_id = hashlib.sha1( | ||||
| 116 | + f"{sector}|{address_full}|{type_line}".encode("utf-8")).hexdigest()[:16] | ||||
| 117 | + | ||||
| 118 | + return Listing( | ||||
| 119 | + source=self.source_id, | ||||
| 120 | + external_id=ext_id, | ||||
| 121 | + url=f"{BASE}/logements-a-louer/" | ||||
| 122 | + f"{[k for k, v in SECTORS.items() if v == sector][0]}/", | ||||
| 123 | + title=f"{type_line} — {address}", | ||||
| 124 | + address=address, | ||||
| 125 | + sector=sector, | ||||
| 126 | + city=city, | ||||
| 127 | + unit_type=unit_type, | ||||
| 128 | + price=price, | ||||
| 129 | + price_label=price_label, | ||||
| 130 | + availability=availability, | ||||
| 131 | + description=" | ".join(lis)[:1500], | ||||
| 132 | + images=images[:20], | ||||
| 133 | + ) | ||||
added
louka/connectors/labonte.py
+193 −0
@@ -0,0 +1,193 @@ | |||||
| 1 | +# ----------------------------------------------------------------------------- | ||||
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | ||||
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 4 | +# connectors/labonte.py : connecteur Gestion Labonté (gestionlabonte.com) | ||||
| 5 | +# Gestionnaire établi de Sherbrooke (66 ans). Site Wix : la page /logements | ||||
| 6 | +# est rendue serveur — répéteur Wix dont chaque item | ||||
| 7 | +# (div id="comp-…__<guid>") porte le prix (« À partir de N $/mois »), | ||||
| 8 | +# l'adresse (h2), le nombre de pièces, le secteur, la photo et le lien vers | ||||
| 9 | +# la page dynamique /logements/<guid> (le GUID du CMS Wix = external_id | ||||
| 10 | +# stable). Les fiches détail (via self.detail, cache BD) ajoutent la | ||||
| 11 | +# description, les paires « Détails du logement » (secteur, pièces, | ||||
| 12 | +# chambres, étage) et l'adresse complète du bloc « Emplacement ». La galerie | ||||
| 13 | +# photos des fiches est chargée côté client (pro-gallery, non SSR) : seule | ||||
| 14 | +# la photo de la carte est disponible. Aucun statut « loué » affiché — la | ||||
| 15 | +# liste ne montre que l'inventaire courant. | ||||
| 16 | +# ----------------------------------------------------------------------------- | ||||
| 17 | +from __future__ import annotations | ||||
| 18 | + | ||||
| 19 | +import hashlib | ||||
| 20 | +import re | ||||
| 21 | + | ||||
| 22 | +from bs4 import BeautifulSoup | ||||
| 23 | + | ||||
| 24 | +from ..schema import Listing, normalize_unit_type, parse_price | ||||
| 25 | +from .base import BaseConnector | ||||
| 26 | + | ||||
| 27 | +BASE = "https://www.gestionlabonte.com" | ||||
| 28 | +LIST_URL = f"{BASE}/logements" | ||||
| 29 | + | ||||
| 30 | +_GUID_RE = re.compile(r"/logements/([0-9a-f-]{36})") | ||||
| 31 | + | ||||
| 32 | +# villes possibles dans l'adresse « Emplacement » (jamais devinées) | ||||
| 33 | +_CITY_RE = re.compile(r"\b(Sherbrooke|Magog|East Angus|Lennoxville)\b", re.I) | ||||
| 34 | + | ||||
| 35 | + | ||||
| 36 | +def _clean_unit_type(raw: str) -> str: | ||||
| 37 | + """normalize_unit_type, mais seulement si le motif est net (« 3.5 », | ||||
| 38 | + « 4,5 », « studio »…) — jamais le texte brut en guise de type.""" | ||||
| 39 | + ut = normalize_unit_type(raw) | ||||
| 40 | + if re.fullmatch(r"\d½\+?|6½\+|Studio|Loft|Chambre|Maison", ut or ""): | ||||
| 41 | + return ut | ||||
| 42 | + return "" | ||||
| 43 | + | ||||
| 44 | + | ||||
| 45 | +def _lines(el) -> list[str]: | ||||
| 46 | + """Lignes de texte nettoyées d'un sous-arbre.""" | ||||
| 47 | + out = [] | ||||
| 48 | + for raw in el.get_text("\n", strip=True).split("\n"): | ||||
| 49 | + t = re.sub(r"\s+", " ", raw).strip() | ||||
| 50 | + if t: | ||||
| 51 | + out.append(t) | ||||
| 52 | + return out | ||||
| 53 | + | ||||
| 54 | + | ||||
| 55 | +class LabonteConnector(BaseConnector): | ||||
| 56 | + source_id = "labonte" | ||||
| 57 | + request_delay = 0.7 | ||||
| 58 | + max_details = 25 # garde-fou fiches détail (vraies requêtes) | ||||
| 59 | + | ||||
| 60 | + def fetch(self) -> list[Listing]: | ||||
| 61 | + html = self.get(LIST_URL).text | ||||
| 62 | + soup = BeautifulSoup(html, "html.parser") | ||||
| 63 | + | ||||
| 64 | + listings: dict[str, Listing] = {} | ||||
| 65 | + # items du répéteur : conteneurs suffixés par le GUID de l'item CMS | ||||
| 66 | + for item in soup.select('div[id*="__"]'): | ||||
| 67 | + link = item.select_one('a[href*="/logements/"]') | ||||
| 68 | + if not link: | ||||
| 69 | + continue | ||||
| 70 | + m = _GUID_RE.search(link.get("href") or "") | ||||
| 71 | + if not m: | ||||
| 72 | + continue | ||||
| 73 | + guid = m.group(1) | ||||
| 74 | + iid = (item.get("id") or "") | ||||
| 75 | + if not iid.endswith(f"__{guid}") or guid in listings: | ||||
| 76 | + continue # garder le conteneur d'item complet, une fois | ||||
| 77 | + | ||||
| 78 | + lines = _lines(item) | ||||
| 79 | + # « À partir de | 1150 | $/mois | 40 rue St-François | | ||||
| 80 | + # Nombre de pièces | 3.5 | Secteur | Centre-ville | En savoir plus » | ||||
| 81 | + h2 = item.select_one("h2") | ||||
| 82 | + address = re.sub(r"\s+", " ", h2.get_text(" ", strip=True)) if h2 else "" | ||||
| 83 | + | ||||
| 84 | + price = None | ||||
| 85 | + price_label = "" | ||||
| 86 | + for i, t in enumerate(lines): | ||||
| 87 | + if t.lower().startswith("à partir de") and i + 1 < len(lines): | ||||
| 88 | + amount = lines[i + 1] | ||||
| 89 | + unit = lines[i + 2] if i + 2 < len(lines) else "" | ||||
| 90 | + price_label = f"À partir de {amount} {unit}".strip() | ||||
| 91 | + price = parse_price(f"{amount} $") | ||||
| 92 | + break | ||||
| 93 | + | ||||
| 94 | + pieces = sector = "" | ||||
| 95 | + for i, t in enumerate(lines): | ||||
| 96 | + if t.lower().startswith("nombre de pièces") and i + 1 < len(lines): | ||||
| 97 | + pieces = lines[i + 1] | ||||
| 98 | + elif t.lower() == "secteur" and i + 1 < len(lines): | ||||
| 99 | + sector = lines[i + 1] | ||||
| 100 | + | ||||
| 101 | + img = item.select_one('img[src*="static.wixstatic.com/media"]') | ||||
| 102 | + images = [] | ||||
| 103 | + if img: | ||||
| 104 | + images.append(img["src"].split("/v1/")[0]) | ||||
| 105 | + | ||||
| 106 | + listings[guid] = Listing( | ||||
| 107 | + source=self.source_id, | ||||
| 108 | + external_id=guid, | ||||
| 109 | + url=f"{BASE}/logements/{guid}", | ||||
| 110 | + title=address or f"Logement {guid[:8]}", | ||||
| 111 | + address=address, | ||||
| 112 | + sector=sector, | ||||
| 113 | + city="Sherbrooke", | ||||
| 114 | + unit_type=_clean_unit_type(pieces), | ||||
| 115 | + price=price, | ||||
| 116 | + price_label=price_label, | ||||
| 117 | + availability="", # non publié par la source | ||||
| 118 | + images=images, | ||||
| 119 | + ) | ||||
| 120 | + | ||||
| 121 | + # fiches détail (cache BD) : description, chambres/étage, adresse complète | ||||
| 122 | + self._fetched = 0 | ||||
| 123 | + for lst in listings.values(): | ||||
| 124 | + key = hashlib.sha1( | ||||
| 125 | + f"{lst.title}|{lst.price_label}|{lst.sector}" | ||||
| 126 | + .encode("utf-8")).hexdigest() | ||||
| 127 | + try: | ||||
| 128 | + payload = self.detail(lst.external_id, key, | ||||
| 129 | + lambda u=lst.url: self._fetch_detail(u)) | ||||
| 130 | + except Exception: | ||||
| 131 | + continue | ||||
| 132 | + self._apply_detail(lst, payload) | ||||
| 133 | + return list(listings.values()) | ||||
| 134 | + | ||||
| 135 | + # -- fiche détail (page dynamique Wix) ------------------------------------------- | ||||
| 136 | + def _fetch_detail(self, url: str) -> dict: | ||||
| 137 | + if self._fetched >= self.max_details: | ||||
| 138 | + raise RuntimeError("budget de fiches détail atteint") | ||||
| 139 | + self._fetched += 1 | ||||
| 140 | + html = self.get(url).text | ||||
| 141 | + soup = BeautifulSoup(html, "html.parser") | ||||
| 142 | + lines = _lines(soup.body or soup) | ||||
| 143 | + out: dict = {} | ||||
| 144 | + | ||||
| 145 | + # description : entre « Description » et « Détails du logement » | ||||
| 146 | + try: | ||||
| 147 | + i = lines.index("Description") | ||||
| 148 | + j = lines.index("Détails du logement") | ||||
| 149 | + if 0 <= i < j: | ||||
| 150 | + out["description"] = " ".join(lines[i + 1:j])[:1500] | ||||
| 151 | + except ValueError: | ||||
| 152 | + pass | ||||
| 153 | + | ||||
| 154 | + # paires « Détails du logement » : Secteur / pièces / chambres / Étage | ||||
| 155 | + for i, t in enumerate(lines): | ||||
| 156 | + low = t.lower() | ||||
| 157 | + if i + 1 >= len(lines): | ||||
| 158 | + break | ||||
| 159 | + if low == "secteur": | ||||
| 160 | + out["sector"] = lines[i + 1] | ||||
| 161 | + elif low.startswith("nombre de pièces"): | ||||
| 162 | + out["pieces"] = lines[i + 1] | ||||
| 163 | + elif low.startswith("nombre de chambres"): | ||||
| 164 | + out["bedrooms"] = lines[i + 1] | ||||
| 165 | + elif low == "étage": | ||||
| 166 | + out["floor"] = lines[i + 1] | ||||
| 167 | + elif low == "emplacement": | ||||
| 168 | + out["full_address"] = lines[i + 1] | ||||
| 169 | + return out | ||||
| 170 | + | ||||
| 171 | + def _apply_detail(self, lst: Listing, d: dict) -> None: | ||||
| 172 | + if not d: | ||||
| 173 | + return | ||||
| 174 | + if d.get("description"): | ||||
| 175 | + lst.description = d["description"] | ||||
| 176 | + if d.get("sector") and not lst.sector: | ||||
| 177 | + lst.sector = d["sector"] | ||||
| 178 | + if d.get("full_address"): | ||||
| 179 | + full = d["full_address"] | ||||
| 180 | + # « 49 Rue King Ouest, Sherbrooke, QC J1H 1P1, Canada » | ||||
| 181 | + lst.address = ", ".join(p.strip() for p in full.split(",")[:2]) | ||||
| 182 | + m = _CITY_RE.search(full) | ||||
| 183 | + if m: | ||||
| 184 | + lst.city = m.group(1).title() | ||||
| 185 | + if not lst.unit_type and d.get("pieces"): | ||||
| 186 | + lst.unit_type = _clean_unit_type(d["pieces"]) | ||||
| 187 | + details: dict = {} | ||||
| 188 | + if str(d.get("bedrooms") or "").isdigit(): | ||||
| 189 | + details["bedrooms"] = int(d["bedrooms"]) | ||||
| 190 | + if str(d.get("floor") or "").isdigit(): | ||||
| 191 | + details["floor"] = int(d["floor"]) | ||||
| 192 | + if details: | ||||
| 193 | + lst.details = {**lst.details, **details} | ||||
added
louka/connectors/lelaureat.py
+136 −0
@@ -0,0 +1,136 @@ | |||||
| 1 | +# ----------------------------------------------------------------------------- | ||||
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | ||||
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 4 | +# connectors/lelaureat.py : connecteur Résidences Le Lauréat | ||||
| 5 | +# (residences-lelaureat.com) — 107 studios étudiants tout inclus rénovés au | ||||
| 6 | +# 2145, rue Galt Ouest, Sherbrooke (bail Régie du logement, non-fumeur, | ||||
| 7 | +# occupation simple). Site WordPress WPBakery d'une seule page : la section | ||||
| 8 | +# « Dimensions des studios » publie 5 TYPES d'unités (Studio Type, Centre | ||||
| 9 | +# Arrière, Coin Est, Coin Ouest, Loft) avec dimensions et prix par variante | ||||
| 10 | +# (étage / demi-sous-sol). Granularité = type d'unité (aucune unité | ||||
| 11 | +# individuelle publiée), comme sig.py. Les inclusions communes (« Notre | ||||
| 12 | +# service tout inclus englobe ») sont reprises en commodités. 1 requête. | ||||
| 13 | +# ----------------------------------------------------------------------------- | ||||
| 14 | +from __future__ import annotations | ||||
| 15 | + | ||||
| 16 | +import re | ||||
| 17 | + | ||||
| 18 | +from bs4 import BeautifulSoup | ||||
| 19 | + | ||||
| 20 | +from ..schema import Listing, strip_accents | ||||
| 21 | +from .base import BaseConnector | ||||
| 22 | + | ||||
| 23 | +BASE = "https://www.residences-lelaureat.com" | ||||
| 24 | +ADDRESS = "2145 Galt Ouest, Sherbrooke, J1K 3A8" | ||||
| 25 | + | ||||
| 26 | +_AMOUNT_RE = re.compile(r"\$\s?(\d{3,4})") | ||||
| 27 | +_VARIANT_IMG = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I) | ||||
| 28 | + | ||||
| 29 | + | ||||
| 30 | +def _slug(name: str) -> str: | ||||
| 31 | + s = strip_accents(name.strip().lower()) | ||||
| 32 | + return re.sub(r"[^a-z0-9]+", "-", s).strip("-") | ||||
| 33 | + | ||||
| 34 | + | ||||
| 35 | +class LelaureatConnector(BaseConnector): | ||||
| 36 | + source_id = "lelaureat" | ||||
| 37 | + request_delay = 0.6 | ||||
| 38 | + | ||||
| 39 | + def fetch(self) -> list[Listing]: | ||||
| 40 | + html = self.get(f"{BASE}/").text | ||||
| 41 | + soup = BeautifulSoup(html, "html.parser") | ||||
| 42 | + | ||||
| 43 | + # inclusions communes : listes de la section « tout inclus » | ||||
| 44 | + common: list[str] = [] | ||||
| 45 | + for marker in ("Notre service tout inclus englobe", | ||||
| 46 | + "Nos appartements sont équipés"): | ||||
| 47 | + el = soup.find(string=re.compile(marker)) | ||||
| 48 | + if not el: | ||||
| 49 | + continue | ||||
| 50 | + ul = el.parent.find_next("ul") | ||||
| 51 | + if ul: | ||||
| 52 | + for li in ul.select("li"): | ||||
| 53 | + t = re.sub(r"\s+", " ", | ||||
| 54 | + li.get_text(" ", strip=True)).strip(" ►") | ||||
| 55 | + if t and t not in common: | ||||
| 56 | + common.append(t) | ||||
| 57 | + | ||||
| 58 | + # galerie du site (photos des studios) | ||||
| 59 | + gallery: list[str] = [] | ||||
| 60 | + for img in soup.select("img[src*='/wp-content/uploads/']"): | ||||
| 61 | + src = _VARIANT_IMG.sub("", str(img.get("src") or "")) | ||||
| 62 | + if src.startswith("http") and src not in gallery \ | ||||
| 63 | + and re.search(r"room-|logement|Un-logement", src): | ||||
| 64 | + gallery.append(src) | ||||
| 65 | + | ||||
| 66 | + # 5 types d'unités : titres « Studio … » / « Loft » de la section | ||||
| 67 | + # dimensions, chacun précédé de sa dimension et suivi de sa liste | ||||
| 68 | + # (prix par variante, particularités) | ||||
| 69 | + listings: list[Listing] = [] | ||||
| 70 | + seen: set[str] = set() | ||||
| 71 | + for h in soup.find_all(["h3", "h4", "h5"]): | ||||
| 72 | + name = re.sub(r"\s+", " ", h.get_text(" ", strip=True)).strip() | ||||
| 73 | + if not re.match(r"^(Studio|Loft)", name) or len(name) > 40: | ||||
| 74 | + continue | ||||
| 75 | + sid = _slug(name) | ||||
| 76 | + if sid in seen: | ||||
| 77 | + continue | ||||
| 78 | + seen.add(sid) | ||||
| 79 | + | ||||
| 80 | + dim_el = h.find_previous(string=re.compile(r"\d+\s*'")) | ||||
| 81 | + dimension = re.sub(r"\s+", " ", str(dim_el)).strip() if dim_el else "" | ||||
| 82 | + | ||||
| 83 | + # toutes les puces entre ce titre de type et le suivant : prix par | ||||
| 84 | + # variante (étage / demi-sous-sol) + particularités | ||||
| 85 | + bullets: list[str] = [] | ||||
| 86 | + for el in h.find_all_next(): | ||||
| 87 | + if el.name in ("h3", "h4", "h5") and re.match( | ||||
| 88 | + r"^(Studio|Loft|Façade|Galerie|Localisation)", | ||||
| 89 | + el.get_text(" ", strip=True)): | ||||
| 90 | + break | ||||
| 91 | + if el.name == "li" or (el.name == "p" and | ||||
| 92 | + "►" in el.get_text()): | ||||
| 93 | + t = re.sub(r"\s+", " ", el.get_text(" ", strip=True)) | ||||
| 94 | + t = t.replace("►", " ").strip() | ||||
| 95 | + t = re.sub(r"\s+", " ", t) | ||||
| 96 | + if t and t not in bullets: | ||||
| 97 | + bullets.append(t) | ||||
| 98 | + | ||||
| 99 | + # prix : « $855 /$825 » (étage) et « $810 » (demi-sous-sol) -> | ||||
| 100 | + # le plus bas des variantes du type | ||||
| 101 | + price_lines = [b for b in bullets if "$" in b] | ||||
| 102 | + price_label = " / ".join(price_lines)[:80] | ||||
| 103 | + amounts = [int(a) for a in _AMOUNT_RE.findall(" ".join(price_lines))] | ||||
| 104 | + price = float(min(amounts)) if amounts else None | ||||
| 105 | + | ||||
| 106 | + # disponibilité : seulement si écrite (ex. Loft « Disponibilité à | ||||
| 107 | + # partir de janvier 2026 ») | ||||
| 108 | + availability = "" | ||||
| 109 | + for b in bullets: | ||||
| 110 | + m = re.search(r"Disponibilit[eé].*$", b) | ||||
| 111 | + if m: | ||||
| 112 | + availability = m.group(0).strip() | ||||
| 113 | + break | ||||
| 114 | + | ||||
| 115 | + desc_parts = [f"Dimensions : {dimension}"] if dimension else [] | ||||
| 116 | + desc_parts += [b for b in bullets if b] | ||||
| 117 | + amenities = [f"Dimensions : {dimension}"] if dimension else [] | ||||
| 118 | + amenities += common[:12] | ||||
| 119 | + | ||||
| 120 | + listings.append(Listing( | ||||
| 121 | + source=self.source_id, | ||||
| 122 | + external_id=sid, | ||||
| 123 | + url=f"{BASE}/", | ||||
| 124 | + title=f"Résidences Le Lauréat — {name} (étudiant tout inclus)", | ||||
| 125 | + address=ADDRESS, | ||||
| 126 | + sector="", | ||||
| 127 | + city="Sherbrooke", | ||||
| 128 | + unit_type="Loft" if name.lower().startswith("loft") else "Studio", | ||||
| 129 | + price=price, | ||||
| 130 | + price_label=price_label, | ||||
| 131 | + availability=availability, | ||||
| 132 | + description=" | ".join(desc_parts)[:1000], | ||||
| 133 | + amenities=amenities, | ||||
| 134 | + images=gallery[:12], | ||||
| 135 | + )) | ||||
| 136 | + return listings | ||||
added
louka/connectors/matinale.py
+160 −0
Large diff (160 lines) is not loaded by default to keep the page fast.Load diff
added
louka/connectors/montagnais.py
+130 −0
Large diff (130 lines) is not loaded by default to keep the page fast.Load diff
added
louka/connectors/morin.py
+223 −0
Large diff (223 lines) is not loaded by default to keep the page fast.Load diff
added
louka/connectors/siagi.py
+105 −0
Large diff (105 lines) is not loaded by default to keep the page fast.Load diff
added
louka/connectors/uptimo.py
+189 −0
Large diff (189 lines) is not loaded by default to keep the page fast.Load diff
added
reports/connectors/agence_sherbrooke.md
+27 −0
Large diff (27 lines) is not loaded by default to keep the page fast.Load diff
added
reports/connectors/alouer_sherbrooke.md
+35 −0
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
added
reports/connectors/ascensio.md
+32 −0
Large diff (32 lines) is not loaded by default to keep the page fast.Load diff
added
reports/connectors/bestlife.md
+35 −0
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
added
reports/connectors/dynamic.md
+45 −0
Large diff (45 lines) is not loaded by default to keep the page fast.Load diff
added
reports/connectors/floria.md
+32 −0
Large diff (32 lines) is not loaded by default to keep the page fast.Load diff
added
reports/connectors/gestimmo_estrie.md
+24 −0
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
added
reports/connectors/immeubles_db.md
+35 −0
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
added
reports/connectors/labonte.md
+37 −0
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
added
reports/connectors/lelaureat.md
+33 −0
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
added
reports/connectors/matinale.md
+38 −0
Large diff (38 lines) is not loaded by default to keep the page fast.Load diff
added
reports/connectors/montagnais.md
+36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added
reports/connectors/morin.md
+33 −0
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
added
reports/connectors/siagi.md
+30 −0
Large diff (30 lines) is not loaded by default to keep the page fast.Load diff
added
reports/connectors/uptimo.md
+36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added
reports/sources-entries/agence_sherbrooke.json
+10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added
reports/sources-entries/alouer_sherbrooke.json
+10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added
reports/sources-entries/ascensio.json
+10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added
reports/sources-entries/bestlife.json
+10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added
reports/sources-entries/dynamic.json
+10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added
reports/sources-entries/floria.json
+10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added
reports/sources-entries/gestimmo_estrie.json
+10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added
reports/sources-entries/immeubles_db.json
+10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added
reports/sources-entries/labonte.json
+10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added
reports/sources-entries/lelaureat.json
+10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added
reports/sources-entries/matinale.json
+10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added
reports/sources-entries/montagnais.json
+10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added
reports/sources-entries/morin.json
+10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added
reports/sources-entries/siagi.json
+10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added
reports/sources-entries/uptimo.json
+10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/agence_sherbrooke/060fe16ec6a569680f3d.html
+1248 −0
Large diff (1,249 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/agence_sherbrooke/191e9e596b9c4934c025.html
+1397 −0
Large diff (1,398 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/agence_sherbrooke/1d9d442f51056162f0d0.html
+1393 −0
Large diff (1,394 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/agence_sherbrooke/2261c4fe67ca9cd614da.html
+1326 −0
Large diff (1,327 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/agence_sherbrooke/3691963c5af4e2f2ee59.html
+1342 −0
Large diff (1,343 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/agence_sherbrooke/3b08a68a2f08f5d2337c.html
+1337 −0
Large diff (1,338 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/agence_sherbrooke/4e6dd07b6211eb816573.html
+1244 −0
Large diff (1,245 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/agence_sherbrooke/5e6748f851e801ee5f03.html
+1236 −0
Large diff (1,237 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/agence_sherbrooke/76d7b9989a3b2bf1f60c.html
+1396 −0
Large diff (1,397 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/agence_sherbrooke/81c1ee29c772da08a271.html
+1424 −0
Large diff (1,425 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/agence_sherbrooke/8a145c6b4feb0c2b4ccb.html
+1357 −0
Large diff (1,358 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/agence_sherbrooke/901061bec7974876dfe4.html
+1411 −0
Large diff (1,412 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/agence_sherbrooke/99cbdb32cffba9f712e5.html
+871 −0
Large diff (871 lines) is not loaded by default to keep the page fast.Load diff
Diff truncated — file too large.