SPB Git

spb/lou-ka

Public

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

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

58 changed files +20,001 −0

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

Menu

Theme