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