SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%

Nouvelles sources + déduplication inter-sources

8 connecteurs (208 → 212), testés en live :
- logisquebec — portail (~9 800 fiches) : sitemaps + JSON-LD, expiration HTTP 410
- duproprio — location par particuliers (~4 200, 0 % chevauchement) : sitemaps gz + JSON-LD, retrait = 302
- louer_ca — réseau Rentals.ca (~1 700) : API GraphQL interne (JWT), 1 annonce par plan d'étage
- roomies — chambres/colocation : JSON-LD Room
- groupe_maurice — 37 résidences pour aînés (RPA) : API REST + grilles de prix
- bons_locataires — agence Montréal : carte MapListPro + fiches WordPress
- omhm — logements abordables ENHARMONIE
- fb_marketplace — Scrapfly ASP, pagination par tranches de prix (scroll bloqué hors login)

Déduplication inter-sources (louka/dedup.py) :
- colonnes dup_of / dup_sources (migration db.py), recalcul idempotent dans ingest.watch() après géocodage
- blocage par clé d'adresse (civique + rue normalisée FR/EN + ville), confirmation type + prix ±4 %
- contrainte « ≤1 annonce par source par groupe » (évite de fusionner des unités distinctes d'un immeuble)
- canonique = source la plus autoritaire (gestionnaire direct > portail > petites annonces)
- web.py n'affiche que les annonces canoniques (dup_of IS NULL) ; dup_sources exposé pour le badge « aussi publiée sur »
- résultat : 147 groupes, 154 doublons masqués sur 18 536 annonces

Utilitaire _detailutil.TtlDetailCache : cache de pages détail avec TTL + budget par sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 17, 2026) parent 3c42232

14 changed files +1,936 −8

modified data/sources.json +81 −1
@@ -2648,6 +2648,86 @@
2648 2648 "connector": "sutton",
2649 2649 "status": "actif",
2650 2650 "region": "Province"
2651 + },
2652 + {
2653 + "id": "logisquebec",
2654 + "name": "LogisQuébec",
2655 + "url": "https://www.logisquebec.com",
2656 + "listing_url": "https://www.logisquebec.com/appartement-a-louer-montreal",
2657 + "sectors": "Province entière — portail locatif (Réglisse Média), ~9 800 annonces",
2658 + "connector": "logisquebec",
2659 + "status": "actif",
2660 + "region": "Province"
2661 + },
2662 + {
2663 + "id": "duproprio",
2664 + "name": "DuProprio (location)",
2665 + "url": "https://duproprio.com",
2666 + "listing_url": "https://duproprio.com/fr/location/montreal",
2667 + "sectors": "Province entière — location par des particuliers (sans courtier), ~4 200 annonces",
2668 + "connector": "duproprio",
2669 + "status": "actif",
2670 + "region": "Province"
2671 + },
2672 + {
2673 + "id": "louer_ca",
2674 + "name": "Louer.ca",
2675 + "url": "https://louer.ca",
2676 + "listing_url": "https://louer.ca/montreal",
2677 + "sectors": "Province entière — portail locatif (réseau Rentals.ca), API GraphQL",
2678 + "connector": "louer_ca",
2679 + "status": "actif",
2680 + "region": "Province"
2681 + },
2682 + {
2683 + "id": "roomies",
2684 + "name": "Roomies.ca",
2685 + "url": "https://www.roomies.ca",
2686 + "listing_url": "https://www.roomies.ca/montreal-qc",
2687 + "sectors": "Grandes villes QC — chambres et colocation",
2688 + "connector": "roomies",
2689 + "status": "actif",
2690 + "region": "Province"
2691 + },
2692 + {
2693 + "id": "groupe_maurice",
2694 + "name": "Le Groupe Maurice",
2695 + "url": "https://www.legroupemaurice.com",
2696 + "listing_url": "https://www.legroupemaurice.com/residences/",
2697 + "sectors": "37 résidences pour aînés (RPA) — Montréal, Laval, Rive-Sud/Nord, Québec, Gatineau",
2698 + "connector": "groupe_maurice",
2699 + "status": "actif",
2700 + "region": "Province"
2701 + },
2702 + {
2703 + "id": "bons_locataires",
2704 + "name": "Bons Locataires",
2705 + "url": "https://www.bonslocataires.com",
2706 + "listing_url": "https://www.bonslocataires.com/logement-appartement-a-louer-enquete-proprio-credit-locataire-visite-virtuelle-360/",
2707 + "sectors": "Montréal, Laval, Longueuil — agence de location",
2708 + "connector": "bons_locataires",
2709 + "status": "actif",
2710 + "region": "Grand Montréal"
2711 + },
2712 + {
2713 + "id": "omhm",
2714 + "name": "OMHM — logements abordables",
2715 + "url": "https://www.omhm.qc.ca",
2716 + "listing_url": "https://www.omhm.qc.ca/fr/soumettre-une-demande/logements-disponibles",
2717 + "sectors": "Montréal — résidences abordables ENHARMONIE (aînés autonomes)",
2718 + "connector": "omhm",
2719 + "status": "actif",
2720 + "region": "Montréal"
2721 + },
2722 + {
2723 + "id": "fb_marketplace",
2724 + "name": "Facebook Marketplace (location)",
2725 + "url": "https://www.facebook.com/marketplace",
2726 + "listing_url": "https://www.facebook.com/marketplace/montreal/propertyrentals",
2727 + "sectors": "Grandes villes QC — annonces de particuliers (Scrapfly)",
2728 + "connector": "fb_marketplace",
2729 + "status": "actif",
2730 + "region": "Province"
2651 2731 }
2652 2732 ]
2653 −}
\ No newline at end of file
2733 +}
modified louka/connectors/_detailutil.py +60 −0
@@ -128,6 +128,66 @@ def enrich(connector, listings, limit, parse_fn, key="v1", fetch_html=None):
128 128 con.close()
129 129
130 130
131 +class TtlDetailCache:
132 + """Cache BD des pages détail AVEC durée de vie, pour les portails où la
133 + fiche est la seule source de données (LogisQuébec, DuProprio…).
134 +
135 + Contrairement à enrich() (cache permanent tant que la clé ne change pas),
136 + chaque fiche est re-visitée après `ttl_days` pour capter les changements
137 + de prix/disponibilité — dans la limite de `budget` requêtes par
138 + synchronisation. Budget épuisé : le payload périmé est réutilisé tel quel
139 + (jamais de trou de données) ; une fiche jamais visitée retourne None et
140 + sera captée à une synchronisation suivante.
141 + """
142 +
143 + def __init__(self, connector, budget: int, ttl_days: float = 7.0,
144 + key: str = "v1", fetch_html=None) -> None:
145 + from .. import db
146 + self.connector = connector
147 + self.con = db.connect()
148 + self.budget = budget
149 + self.ttl = ttl_days * 86400
150 + self.key = key
151 + self.fetch_html = fetch_html or (lambda u: connector.get(u).text)
152 +
153 + def get(self, external_id: str, url: str, parse_fn) -> dict | None:
154 + import time as _time
155 + from .. import db
156 + row = self.con.execute(
157 + "SELECT key, payload, fetched_at FROM detail_cache"
158 + " WHERE source=? AND external_id=?",
159 + (self.connector.source_id, str(external_id))).fetchone()
160 + stale = None
161 + if row and row["payload"]:
162 + try:
163 + stale = json.loads(row["payload"])
164 + except ValueError:
165 + stale = None
166 + if (stale is not None and row["key"] == self.key
167 + and _time.time() - (row["fetched_at"] or 0) < self.ttl):
168 + return stale # frais : aucun trafic
169 + if self.budget <= 0:
170 + return stale # périmé toléré / None si jamais vu
171 + self.budget -= 1
172 + try:
173 + payload = parse_fn(self.fetch_html(url)) or {}
174 + except Exception as exc: # 404/410 = fiche retirée
175 + code = getattr(getattr(exc, "response", None), "status_code", None)
176 + if code in (404, 410):
177 + payload = {"gone": True}
178 + else:
179 + return stale # erreur réseau : on garde l'ancien
180 + db.put_cached_detail(self.con, self.connector.source_id,
181 + str(external_id), self.key, payload)
182 + return payload
183 +
184 + def close(self) -> None:
185 + try:
186 + self.con.close()
187 + except Exception:
188 + pass
189 +
190 +
131 191 def apply_detail(lst: Listing, d: dict) -> None:
132 192 """Applique un payload détail au Listing sans écraser les valeurs déjà
133 193 présentes (sauf images : on garde la plus grande galerie)."""
added louka/connectors/bons_locataires.py +160 −0
@@ -0,0 +1,160 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/bons_locataires.py : Bons Locataires (bonslocataires.com) — agence
5 +# de location Montréal/Laval/Longueuil. WordPress, aucun anti-bot (seul
6 +# mod_security bloque les sitemaps XML — on ne les utilise pas).
7 +# Source de vérité : la page carte MapListPro (~8 Mo) dont le tableau JSON
8 +# inline contient les 1 600+ emplacements avec lat/lng, taxonomies
9 +# (région/quartier/taille) et description complète. Le site garde les
10 +# logements loués sur la carte (« DÉJÀ LOUÉ », pin PouceVert) : on ne
11 +# conserve que les annonces encore offertes, et on valide que leur page
12 +# (locationUrl) répond toujours 200 (certains vieux liens sont morts).
13 +# Prix et disponibilité en texte libre -> regex. ~20-40 annonces actives.
14 +# -----------------------------------------------------------------------------
15 +from __future__ import annotations
16 +
17 +import html as H
18 +import json
19 +import re
20 +
21 +from ..schema import Listing, normalize_unit_type
22 +from .base import BaseConnector
23 +
24 +MAP_PAGE = ("https://www.bonslocataires.com/logement-appartement-a-louer-"
25 + "enquete-proprio-credit-locataire-visite-virtuelle-360/")
26 +
27 +_PRICE_RE = re.compile(r"(\d{3,4})\s*\$\s*(?:/|par)?\s*mois", re.I)
28 +_PRICE_ALT_RE = re.compile(r"(\d{3,4})\s*\$")
29 +_IMG_RE = re.compile(r'src="(https?://www\.bonslocataires\.com/wp-content/'
30 + r'uploads/[^"]+)"')
31 +_LOC_ID_RE = re.compile(r"loc-(\d+)")
32 +_TAG_RE = re.compile(r"<[^>]+>")
33 +
34 +
35 +def _texte(fragment: str) -> str:
36 + return re.sub(r"\s+\n", "\n", H.unescape(_TAG_RE.sub("", fragment or ""))).strip()
37 +
38 +
39 +class BonsLocatairesConnector(BaseConnector):
40 + source_id = "bons_locataires"
41 + request_delay = 1.0
42 + timeout = 120 # la page carte fait ~8 Mo
43 +
44 + def _map_objects(self) -> list[dict]:
45 + import time
46 + html = ""
47 + for attempt in range(3): # mod_security coupe parfois la page 8 Mo
48 + try:
49 + html = self.get(MAP_PAGE).text
50 + break
51 + except Exception:
52 + if attempt == 2:
53 + raise
54 + time.sleep(20 * (attempt + 1))
55 + i = html.find('[{"title":')
56 + if i < 0:
57 + return []
58 + depth, end = 0, None
59 + for j in range(i, len(html)):
60 + if html[j] == "[":
61 + depth += 1
62 + elif html[j] == "]":
63 + depth -= 1
64 + if depth == 0:
65 + end = j + 1
66 + break
67 + try:
68 + return json.loads(html[i:end])
69 + except ValueError:
70 + return []
71 +
72 + @staticmethod
73 + def _available(obj: dict) -> bool:
74 + blob = (obj.get("title", "") + (obj.get("description") or "")
75 + + (obj.get("icon") or "") + (obj.get("pinImageUrl") or ""))
76 + return "LOU" not in blob.upper() or "PouceVert" not in blob
77 +
78 + def fetch(self) -> list[Listing]:
79 + out: list[Listing] = []
80 + for obj in self._map_objects():
81 + desc_html = obj.get("description") or ""
82 + blob = (obj.get("title", "") + desc_html).upper()
83 + pin = (obj.get("pinImageUrl") or "") + (obj.get("icon") or "")
84 + if "DÉJÀ LOU" in blob or "DEJA LOU" in blob or "LOUÉ!" in blob \
85 + or "PouceVert" in pin:
86 + continue
87 + url = (obj.get("locationUrl") or "").replace("http://", "https://")
88 + if not url:
89 + continue
90 + try: # vieux liens morts -> annonce écartée
91 + page_html = self.get(url).text
92 + except Exception:
93 + continue
94 + m = _LOC_ID_RE.search(obj.get("cssClass") or "")
95 + eid = m.group(1) if m else url.rstrip("/").rsplit("/", 1)[-1]
96 +
97 + cats = obj.get("customCategories") or {}
98 + def _first(tax: str) -> str:
99 + terms = cats.get(tax) or []
100 + return terms[0].get("name", "") if terms else ""
101 + city = _first("map_location_categories_region") or "Montréal"
102 + sector = _first("map_location_categories_city")
103 + taille = _first("map_location_categories_taille")
104 +
105 + title = _texte(obj.get("title", "")).strip("* ")
106 + unit_type = normalize_unit_type(taille or title)
107 + if not re.match(r"^\d½$|^6½\+$|^Studio$|^Loft$|^Chambre", unit_type or ""):
108 + unit_type = ""
109 +
110 + # la fiche WordPress (déjà téléchargée pour la validation) est plus
111 + # riche que le popup carte : prix, galerie, disponibilité
112 + page_main = ""
113 + m = re.search(r'<div class="entry-content[^"]*">(.*?)</div>\s*'
114 + r'(?:<footer|<div class="entry-footer)', page_html, re.S)
115 + if not m:
116 + m = re.search(r"<article\b.*?</article>", page_html, re.S)
117 + if m:
118 + page_main = m.group(0)
119 + page_text = _texte(page_main)
120 +
121 + description = _texte(desc_html)
122 + if 100 < len(page_text) and len(page_text) > len(description):
123 + description = page_text
124 + price = None # le popup carte prime : texte ciblé
125 + for texte in (_texte(desc_html), page_text):
126 + m = _PRICE_RE.search(texte) or _PRICE_ALT_RE.search(texte)
127 + if m and 400 <= float(m.group(1)) <= 5000:
128 + price = float(m.group(1))
129 + break
130 + availability = ""
131 + m = re.search(r"[Dd]isponible[^.\n<]{0,70}", description)
132 + if m:
133 + availability = m.group(0).strip()
134 + images = []
135 + for u in _IMG_RE.findall(page_main) + _IMG_RE.findall(desc_html):
136 + u = u.replace("http://", "https://")
137 + if u not in images:
138 + images.append(u)
139 + try:
140 + lat, lng = float(obj.get("latitude")), float(obj.get("longitude"))
141 + except (TypeError, ValueError):
142 + lat = lng = None
143 + out.append(Listing(
144 + source=self.source_id,
145 + external_id=str(eid),
146 + url=url,
147 + title=title,
148 + sector=sector,
149 + city=city,
150 + unit_type=unit_type,
151 + price=price,
152 + price_label=(f"{price:,.0f} $/mois".replace(",", " ")
153 + if price else ""),
154 + availability=availability,
155 + description=description[:6000],
156 + images=images,
157 + lat=lat,
158 + lng=lng,
159 + ))
160 + return out
added louka/connectors/duproprio.py +225 −0
@@ -0,0 +1,225 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/duproprio.py : DuProprio (duproprio.com) — section LOCATION
5 +# ~4 200 annonces de particuliers (sans courtier : zéro chevauchement MLS).
6 +# Découverte par les 21 sitemaps régionaux `sitemaps/fr/<region>-listings.xml.gz`
7 +# filtrés sur `/fr/location/`. Fiches SSR (Laravel/CloudFront, aucun anti-bot) :
8 +# JSON-LD `Apartment` (adresse + code postal + chambres/SDB + amenityFeature)
9 +# et `RealEstateListing` (offers.price + availabilityStarts), lat/lng inline,
10 +# superficie « 970 pi² », galerie dans un JSON échappé ("photos":[…]).
11 +# Fiche retirée -> 302 vers la page parente (détectée par l'URL finale sans
12 +# `/hab-`). Cache détail avec TTL (_detailutil.TtlDetailCache).
13 +# Respect robots.txt : ne jamais toucher /webservice/, /fr-ca/api, /api-proxy.
14 +# -----------------------------------------------------------------------------
15 +from __future__ import annotations
16 +
17 +import gzip
18 +import html as H
19 +import os
20 +import re
21 +
22 +from ..schema import Listing
23 +from .base import BaseConnector
24 +from . import _detailutil as du
25 +
26 +BASE = "https://duproprio.com"
27 +SITEMAP_INDEX = f"{BASE}/sitemaps/fr/index.xml.gz"
28 +
29 +_LOC_RE = re.compile(r"<loc>(.*?)</loc>")
30 +# .../fr/location/<region>/<ville>/<type>-a-louer/hab-<slug>-<ID>
31 +_FICHE_RE = re.compile(
32 + r"^https://duproprio\.com/fr/location/([a-z0-9-]+)/([a-z0-9-]+)/"
33 + r"([a-z0-9-]+)-a-louer/hab-[a-z0-9-]+-(\d+)$")
34 +_GEO_RE = re.compile(r'"latitude":([\d.-]+),"longitude":([\d.-]+)')
35 +_AREA_RE = re.compile(r"([\d\s,.]{1,10})\s*pi²")
36 +_PHOTO_RE = re.compile(
37 + r"photos\\?/public\\?/for_rent\\?/[0-9]+\\?/[0-9]+\\?/[a-z0-9-]+-(\d+)\.jpg")
38 +_PHOTO_FULL_RE = re.compile(
39 + r"photos[\\/]+public[\\/]+for_rent[\\/]+(\d+)[\\/]+(\d+)[\\/]+([a-z0-9-]+)-(\d+)\.jpg")
40 +_TYPE_HALF_RE = re.compile(r"^(\d+)-1-2$")
41 +
42 +# types non résidentiels (premier mot du slug type) exclus du parc
43 +_EXCLUDED_TYPES = {"commerce", "bureau", "local", "entrepot", "espace",
44 + "terrain", "garage", "stationnement", "industriel"}
45 +
46 +DETAIL_LIMIT = int(os.environ.get("LOUKA_DUPROPRIO_DETAIL_LIMIT", "800"))
47 +TTL_DAYS = float(os.environ.get("LOUKA_DUPROPRIO_TTL_DAYS", "7"))
48 +MAX_FICHES = int(os.environ.get("LOUKA_DUPROPRIO_MAX", "0")) # 0 = tout
49 +
50 +
51 +def _parse_fiche(html: str) -> dict:
52 + out: dict = {}
53 + for node in du.ld_nodes(html):
54 + t = node.get("@type") or ""
55 + if t in ("Apartment", "House", "SingleFamilyResidence", "Accommodation"):
56 + out["title"] = (node.get("name") or "").strip()
57 + addr = node.get("address") or {}
58 + out["address"] = (addr.get("streetAddress") or "").strip()
59 + out["city"] = (addr.get("addressLocality") or "").strip()
60 + out["postal_code"] = (addr.get("postalCode") or "").strip()
61 + for src, lab in (("numberOfBedrooms", "Chambres"),
62 + ("numberOfFullBathrooms", "Salles de bain"),
63 + ("floorLevel", "Étage")):
64 + if node.get(src):
65 + out.setdefault("details", {})[lab] = str(node[src])
66 + if node.get("numberOfBedrooms"):
67 + out["bedrooms"] = str(node["numberOfBedrooms"])
68 + feats = node.get("amenityFeature") or []
69 + names = [f.get("name", "").strip() for f in feats
70 + if isinstance(f, dict) and f.get("name")]
71 + if names:
72 + out["amenities"] = names
73 + if node.get("description"):
74 + out["description"] = H.unescape(str(node["description"])).strip()[:6000]
75 + elif t == "RealEstateListing":
76 + offers = node.get("offers") or {}
77 + try:
78 + out["price"] = float(str(offers.get("price")).replace(",", "."))
79 + except (TypeError, ValueError):
80 + pass
81 + if offers.get("availabilityStarts"):
82 + out["availability_date"] = str(offers["availabilityStarts"])[:10]
83 +
84 + m = _GEO_RE.search(html)
85 + if m:
86 + out["lat"], out["lng"] = float(m.group(1)), float(m.group(2))
87 + m = _AREA_RE.search(html)
88 + if m:
89 + try:
90 + area = float(m.group(1).replace(" ", "").replace(",", ""))
91 + if 80 <= area <= 20000:
92 + out["area_sqft"] = area
93 + except ValueError:
94 + pass
95 +
96 + # galerie : URLs (souvent échappées \/) dédupliquées par id photo, en 1600 px
97 + photos, seen = [], set()
98 + for m in _PHOTO_FULL_RE.finditer(html):
99 + ym, lid, slug, pid = m.groups()
100 + if pid in seen:
101 + continue
102 + seen.add(pid)
103 + # le slug embarque la taille en avant-dernier segment : la re-forcer à 1600
104 + slug1600 = re.sub(r"-\d+$", "-1600", slug)
105 + photos.append(f"https://photos.duproprio.com/photos/public/for_rent/"
106 + f"{ym}/{lid}/{slug1600}-{pid}.jpg")
107 + if photos:
108 + out["images"] = photos
109 + return out
110 +
111 +
112 +class DuProprioConnector(BaseConnector):
113 + source_id = "duproprio"
114 + request_delay = 0.8
115 +
116 + def _gz(self, url: str) -> str:
117 + resp = self.get(url)
118 + try:
119 + return gzip.decompress(resp.content).decode("utf-8", "replace")
120 + except (OSError, EOFError):
121 + return resp.text
122 +
123 + def _fiche_urls(self) -> list[tuple[str, str, str, str]]:
124 + """(url, ville, type_slug, id) des fiches location, tous sitemaps."""
125 + index = self._gz(SITEMAP_INDEX)
126 + subs = [u for u in _LOC_RE.findall(index) if u.endswith("-listings.xml.gz")]
127 + out, seen = [], set()
128 + for sub in subs:
129 + try:
130 + xml = self._gz(sub)
131 + except Exception:
132 + continue
133 + for u in _LOC_RE.findall(xml):
134 + m = _FICHE_RE.match(u)
135 + if m and m.group(3).split("-")[0] in _EXCLUDED_TYPES:
136 + continue
137 + if m and m.group(4) not in seen:
138 + seen.add(m.group(4))
139 + out.append((u, m.group(2), m.group(3), m.group(4)))
140 + return out
141 +
142 + def _fetch_fiche(self, url: str) -> str:
143 + """GET d'une fiche ; une redirection vers la page parente = retirée."""
144 + resp = self.get(url, allow_redirects=True)
145 + if "/hab-" not in resp.url:
146 + return "" # 302 « annonce disparue »
147 + return resp.text
148 +
149 + @staticmethod
150 + def _unit_type(type_slug: str, bedrooms: str | None) -> str:
151 + m = _TYPE_HALF_RE.match(type_slug)
152 + if m:
153 + n = int(m.group(1))
154 + return "6½+" if n >= 6 else f"{n}½"
155 + base = {"studio": "Studio", "loft": "Loft", "chambre": "Chambre",
156 + "maison": "Maison", "condo": "", "appartement": ""}
157 + label = base.get(type_slug.split("-")[0], "")
158 + if not label and bedrooms and bedrooms.isdigit():
159 + n = int(bedrooms) + 2
160 + return "6½+" if n >= 6 else f"{n}½"
161 + return label or type_slug.replace("-", " ").capitalize()
162 +
163 + def fetch(self) -> list[Listing]:
164 + fiches = self._fiche_urls()
165 + if MAX_FICHES:
166 + fiches = fiches[:MAX_FICHES]
167 + cache = du.TtlDetailCache(self, budget=DETAIL_LIMIT, ttl_days=TTL_DAYS,
168 + fetch_html=self._fetch_fiche)
169 + out: list[Listing] = []
170 + try:
171 + for url, _ville, type_slug, eid in fiches:
172 + payload = cache.get(eid, url, _parse_fiche)
173 + if payload is None: # jamais visitée + budget épuisé
174 + continue
175 + if not payload or payload.get("gone"):
176 + continue # fiche retirée (302) ou page vide
177 + price = payload.get("price")
178 + # availabilityStarts dans le passé = déjà libre aujourd'hui
179 + avail = payload.get("availability_date")
180 + if avail:
181 + import datetime as _dt
182 + if avail <= _dt.date.today().isoformat():
183 + avail = "now"
184 + # « Ahuntsic (Montréal) » -> secteur + ville ; « X (X) » -> X
185 + locality = payload.get("city") or ""
186 + sector = ""
187 + m = re.match(r"^(.*?)\s*\((.+)\)\s*$", locality)
188 + if m:
189 + inner, outer = m.group(1).strip(), m.group(2).strip()
190 + if inner.lower() == outer.lower():
191 + locality, sector = inner, ""
192 + else:
193 + locality, sector = outer, inner
194 + lst = Listing(
195 + source=self.source_id,
196 + external_id=str(eid),
197 + url=url,
198 + title=payload.get("title") or "",
199 + address=payload.get("address") or "",
200 + sector=sector,
201 + city=locality,
202 + unit_type=self._unit_type(type_slug, payload.get("bedrooms")),
203 + price=price,
204 + price_label=(f"{price:,.0f} $/mois".replace(",", " ")
205 + if price else ""),
206 + availability_date=avail,
207 + availability=("Libre immédiatement" if avail == "now"
208 + else f"Libre le {avail}" if avail else ""),
209 + area_sqft=payload.get("area_sqft"),
210 + description=payload.get("description") or "",
211 + amenities=payload.get("amenities") or [],
212 + details=payload.get("details") or {},
213 + images=payload.get("images") or [],
214 + lat=payload.get("lat"),
215 + lng=payload.get("lng"),
216 + )
217 + low = [a.lower() for a in lst.amenities]
218 + if any("animaux" in a and "permis" in a for a in low):
219 + lst.pets = "oui"
220 + if any(a.startswith("meublé") for a in low):
221 + lst.furnished = True
222 + out.append(lst)
223 + finally:
224 + cache.close()
225 + return out
added louka/connectors/fb_marketplace.py +211 −0
@@ -0,0 +1,211 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/fb_marketplace.py : Facebook Marketplace — catégorie location
5 +# (propertyrentals) par ville du Québec. Annonces de particuliers, fort
6 +# complément aux gestionnaires. Accès via Scrapfly ASP (render_js + proxy
7 +# résidentiel CA), SANS compte ni cookie (le scraping public déconnecté est
8 +# couvert par Meta v. Bright Data 2024). Les données vivent dans des balises
9 +# <script type="application/json"> (objets marketplace_listing_title).
10 +# Le scroll infini est bloqué hors connexion (~25 annonces/URL, mur de
11 +# login) : on pagine par TRANCHES DE PRIX (minPrice/maxPrice), qui renvoient
12 +# des lots quasi disjoints, et on accumule sur plusieurs synchronisations.
13 +# Détail (/marketplace/item/<id>) : lat/lng + description (Scrapfly, coûteux)
14 +# uniquement pour les nouvelles annonces via le cache. Type d'unité et
15 +# disponibilité déduits du titre/description (pas d'attributs structurés
16 +# hors connexion). ⚠️ Coût Scrapfly élevé + données personnelles (Loi 25) :
17 +# ne jamais republier nom/téléphone du vendeur, garder le lien sortant.
18 +# -----------------------------------------------------------------------------
19 +from __future__ import annotations
20 +
21 +import json
22 +import os
23 +import re
24 +
25 +from ..schema import Listing, normalize_unit_type
26 +from .base import BaseConnector
27 +from . import _detailutil as du
28 +
29 +BASE = "https://www.facebook.com/marketplace"
30 +
31 +# villes québécoises (slug Marketplace) -> nom d'affichage
32 +CITIES = {
33 + "montreal": "Montréal", "laval": "Laval", "longueuil": "Longueuil",
34 + "quebec": "Québec", "gatineau": "Gatineau", "sherbrooke": "Sherbrooke",
35 + "trois-rivieres": "Trois-Rivières", "levis": "Lévis",
36 + "saguenay": "Saguenay", "drummondville": "Drummondville",
37 +}
38 +# tranches de prix (bornes en $) : le flux déconnecté sert un sous-ensemble
39 +# tournant, chaque tranche renvoie un lot quasi disjoint
40 +PRICE_BANDS = [(0, 800), (800, 1100), (1100, 1400), (1400, 1700),
41 + (1700, 2100), (2100, 2800), (2800, 6000)]
42 +
43 +# garde-fous de prix mensuel (rejette « $90 » = /nuit, et les valeurs à vendre)
44 +PRICE_MIN, PRICE_MAX = 300, 12000
45 +
46 +DETAIL_LIMIT = int(os.environ.get("LOUKA_FBMP_DETAIL_LIMIT", "120"))
47 +TTL_DAYS = float(os.environ.get("LOUKA_FBMP_TTL_DAYS", "10"))
48 +CITY_LIMIT = os.environ.get("LOUKA_FBMP_CITIES", "") # ex. "montreal,laval"
49 +
50 +
51 +def _walk_listings(html: str) -> dict:
52 + """Objets d'annonce (marketplace_listing_title) des <script> JSON."""
53 + out: dict = {}
54 +
55 + def walk(o):
56 + if isinstance(o, dict):
57 + if "marketplace_listing_title" in o and o.get("id"):
58 + out[str(o["id"])] = o
59 + for v in o.values():
60 + walk(v)
61 + elif isinstance(o, list):
62 + for v in o:
63 + walk(v)
64 +
65 + for m in re.finditer(r'<script type="application/json"[^>]*>(.*?)</script>',
66 + html, re.S):
67 + try:
68 + walk(json.loads(m.group(1)))
69 + except ValueError:
70 + continue
71 + return out
72 +
73 +
74 +def _price(obj: dict) -> float | None:
75 + pr = obj.get("listing_price") or {}
76 + for k in ("amount", "amount_with_offset_in_currency"):
77 + v = pr.get(k)
78 + if v is None:
79 + continue
80 + try:
81 + val = float(v)
82 + except (TypeError, ValueError):
83 + continue
84 + if k == "amount_with_offset_in_currency" and val > 100000:
85 + val /= 100.0 # offset en cents
86 + if PRICE_MIN <= val <= PRICE_MAX:
87 + return round(val, 0)
88 + return None
89 +
90 +
91 +def _unit_type(title: str, desc: str = "") -> str:
92 + blob = f"{title} {desc}"
93 + ut = normalize_unit_type(title)
94 + if re.match(r"^\d½$|^6½\+$|^Studio$|^Loft$", ut or ""):
95 + return ut
96 + m = re.search(r"(\d+)\s*(?:bed|bedroom|chambre|cc|br)\b", blob, re.I)
97 + if m:
98 + n = int(m.group(1)) + 2
99 + return "6½+" if n >= 6 else f"{n}½"
100 + if re.search(r"\bstudio|bachelor\b", blob, re.I):
101 + return "Studio"
102 + if re.search(r"\b(?:private\s+)?room\b|chambre", blob, re.I):
103 + return "Chambre"
104 + return ""
105 +
106 +
107 +class FacebookMarketplaceConnector(BaseConnector):
108 + source_id = "fb_marketplace"
109 + request_delay = 1.0
110 +
111 + def _scrape(self, url: str) -> str:
112 + return self.get_scrapfly(url, render_js=True, asp=True, country="ca",
113 + proxy_pool="public_residential_pool",
114 + rendering_wait=5000)
115 +
116 + def _parse_detail(self, html: str) -> dict:
117 + listings = _walk_listings(html)
118 + # la fiche contient l'annonce cible + des « similaires » : on prend
119 + # celle qui possède une localisation ou une description
120 + best = {}
121 + for o in listings.values():
122 + loc = o.get("location") or {}
123 + desc = (o.get("redacted_description") or {}).get("text") or ""
124 + score = (1 if loc.get("latitude") else 0) + (1 if desc else 0)
125 + if score > best.get("_score", -1):
126 + best = {"_score": score, "obj": o}
127 + o = best.get("obj") or {}
128 + out: dict = {}
129 + loc = o.get("location") or {}
130 + if loc.get("latitude") and loc.get("longitude"):
131 + out["lat"] = loc["latitude"]
132 + out["lng"] = loc["longitude"]
133 + desc = (o.get("redacted_description") or {}).get("text") or ""
134 + if desc:
135 + out["description"] = desc[:6000]
136 + lt = o.get("location_text") or {}
137 + if lt.get("text"):
138 + out["location_text"] = lt["text"]
139 + photos = []
140 + for p in o.get("listing_photos") or []:
141 + uri = ((p.get("image") or {}).get("uri")) or ""
142 + if uri and uri not in photos:
143 + photos.append(uri)
144 + if photos:
145 + out["images"] = photos
146 + return out
147 +
148 + def _search(self, slug: str) -> dict:
149 + found: dict = {}
150 + for lo, hi in PRICE_BANDS:
151 + url = (f"{BASE}/{slug}/propertyrentals"
152 + f"?minPrice={lo}&maxPrice={hi}&sortBy=creation_time_descend")
153 + try:
154 + html = self._scrape(url)
155 + except Exception:
156 + continue
157 + for lid, obj in _walk_listings(html).items():
158 + found.setdefault(lid, obj)
159 + return found
160 +
161 + def fetch(self) -> list[Listing]:
162 + if not os.environ.get("SCRAPFLY_KEY"):
163 + raise RuntimeError("SCRAPFLY_KEY manquant (voir .env)")
164 + cities = dict(CITIES)
165 + if CITY_LIMIT:
166 + wanted = {c.strip() for c in CITY_LIMIT.split(",")}
167 + cities = {k: v for k, v in CITIES.items() if k in wanted}
168 +
169 + cache = du.TtlDetailCache(self, budget=DETAIL_LIMIT, ttl_days=TTL_DAYS,
170 + key="v1", fetch_html=self._scrape)
171 + out: dict[str, Listing] = {}
172 + try:
173 + for slug, city in cities.items():
174 + for lid, obj in self._search(slug).items():
175 + if lid in out:
176 + continue
177 + title = obj.get("marketplace_listing_title") or ""
178 + price = _price(obj)
179 + if price is None:
180 + continue # sans prix mensuel valable : ignorer
181 + loc = obj.get("location") or {}
182 + coords = loc.get("reverse_geocode") or {}
183 + obj_city = (coords.get("city")
184 + or (coords.get("city_page") or {}).get("display_name")
185 + or city)
186 + photo = ((obj.get("primary_listing_photo") or {})
187 + .get("image") or {}).get("uri") or ""
188 + url = f"{BASE}/item/{lid}/"
189 +
190 + detail = cache.get(lid, url, self._parse_detail) or {}
191 + desc = detail.get("description") or ""
192 + lst = Listing(
193 + source=self.source_id,
194 + external_id=lid,
195 + url=url,
196 + title=title[:200],
197 + city=obj_city,
198 + unit_type=_unit_type(title, desc),
199 + price=price,
200 + price_label=f"{price:,.0f} $/mois".replace(",", " "),
201 + description=desc,
202 + images=detail.get("images") or ([photo] if photo else []),
203 + lat=detail.get("lat"),
204 + lng=detail.get("lng"),
205 + )
206 + if obj.get("is_sold") or obj.get("is_pending"):
207 + continue # déjà loué / en attente
208 + out[lid] = lst
209 + finally:
210 + cache.close()
211 + return list(out.values())
added louka/connectors/groupe_maurice.py +98 −0
@@ -0,0 +1,98 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/groupe_maurice.py : Le Groupe Maurice — 37 résidences pour aînés
5 +# (RPA) au Québec (Montréal, Laval, Rive-Sud/Nord, Québec, Gatineau…).
6 +# WordPress avec API REST maison : /wp-json/api/v1/residences retourne les
7 +# 37 résidences géolocalisées (adresse, lat/lng, ville, url, vignette).
8 +# Chaque page résidence (SSR) expose la grille des types d'unités dans des
9 +# <article class="y-bordered"> : « Studio | 408 pi.ca à 628 pi.ca |
10 +# À partir de 2 286 $ par mois ». Une annonce Lou-Ka par type × résidence,
11 +# prix plancher « à partir de », étiquetée Résidence pour aînés.
12 +# -----------------------------------------------------------------------------
13 +from __future__ import annotations
14 +
15 +import re
16 +
17 +from bs4 import BeautifulSoup
18 +
19 +from ..schema import Listing, normalize_unit_type
20 +from .base import BaseConnector
21 +
22 +API = "https://www.legroupemaurice.com/wp-json/api/v1/residences"
23 +
24 +_ROW_RE = re.compile(
25 + r"^(?P<type>.+?)\s+(?P<a1>[\d\s]+)\s*pi\.?\s*ca(?:\s*à\s*(?P<a2>[\d\s]+)\s*pi\.?\s*ca)?"
26 + r"\s*À partir de\s*(?P<prix>[\d\s]+)\s*\$\s*par mois", re.S)
27 +_TAG_RE = re.compile(r"<[^>]+>")
28 +
29 +
30 +class GroupeMauriceConnector(BaseConnector):
31 + source_id = "groupe_maurice"
32 + request_delay = 0.6
33 +
34 + def _rows(self, page_html: str) -> list[dict]:
35 + soup = BeautifulSoup(page_html, "html.parser")
36 + out = []
37 + for art in soup.find_all("article"):
38 + if "y-bordered" not in (art.get("class") or []):
39 + continue
40 + text = re.sub(r"\s+", " ", art.get_text(" ", strip=True))
41 + m = _ROW_RE.match(text)
42 + if not m:
43 + continue
44 + try:
45 + price = float(m.group("prix").replace(" ", ""))
46 + area = float(m.group("a1").replace(" ", ""))
47 + except ValueError:
48 + continue
49 + out.append({"type": m.group("type").strip(),
50 + "price": price, "area": area})
51 + return out
52 +
53 + def fetch(self) -> list[Listing]:
54 + data = self.get(API).json()
55 + residences = (data.get("result") or {})
56 + if isinstance(residences, dict):
57 + residences = list(residences.values())
58 + out: list[Listing] = []
59 + for res in residences:
60 + url = res.get("url") or ""
61 + if not url:
62 + continue
63 + slug = url.rstrip("/").rsplit("/", 1)[-1]
64 + try:
65 + rows = self._rows(self.get(url).text)
66 + except Exception:
67 + continue
68 + desc = _TAG_RE.sub("", res.get("excerpt") or "").strip()
69 + try:
70 + lat, lng = float(res.get("latitude")), float(res.get("longitude"))
71 + except (TypeError, ValueError):
72 + lat = lng = None
73 + for row in rows:
74 + raw_type = row["type"]
75 + unit_type = normalize_unit_type(raw_type)
76 + details = {"Résidence pour aînés": "oui"}
77 + if "den" in raw_type.lower() or "+" in raw_type:
78 + details["Type d'unité (résidence)"] = raw_type
79 + eid = f"{slug}-{re.sub(r'[^a-z0-9]+', '-', raw_type.lower()).strip('-')}"
80 + out.append(Listing(
81 + source=self.source_id,
82 + external_id=eid,
83 + url=url,
84 + title=f"{raw_type} — Résidence {res.get('residence', slug)}",
85 + address=res.get("address") or "",
86 + city=res.get("city") or "",
87 + unit_type=unit_type,
88 + price=row["price"],
89 + price_label=f"À partir de {row['price']:,.0f} $ par mois"
90 + .replace(",", " "),
91 + area_sqft=row["area"],
92 + description=desc,
93 + details=details,
94 + images=[res["thumbnail"]] if res.get("thumbnail") else [],
95 + lat=lat,
96 + lng=lng,
97 + ))
98 + return out
added louka/connectors/logisquebec.py +222 −0
@@ -0,0 +1,222 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/logisquebec.py : LogisQuébec (logisquebec.com) — portail locatif
5 +# québécois (Réglisse Média). ~9 800 fiches location, province entière.
6 +# Découverte par les sitemaps (99,7 % du parc vérifié vs compteurs des pages
7 +# liste) : URLs de fiche « {type}-a-louer-{ville}-l###### ». Chaque fiche
8 +# (SSR, Cloudflare tolérant) expose un JSON-LD complet : SingleFamilyResidence
9 +# (adresse + code postal + GPS + téléphone) et Product (nom, description,
10 +# offers.price, sku = id). Galerie lightGallery, disponibilité et
11 +# caractéristiques actives (span .caracteristique-texte) dans le HTML.
12 +# Fiche expirée -> HTTP 410 Gone (retrait immédiat). Cache détail avec TTL
13 +# (les prix changent) via _detailutil.TtlDetailCache.
14 +# -----------------------------------------------------------------------------
15 +from __future__ import annotations
16 +
17 +import os
18 +import re
19 +
20 +from bs4 import BeautifulSoup
21 +
22 +from ..schema import Listing, normalize_unit_type
23 +from .base import BaseConnector
24 +from . import _detailutil as du
25 +
26 +BASE = "https://www.logisquebec.com"
27 +
28 +# type d'URL -> (type d'unité par défaut, court terme ?) ; les slugs
29 +# commerciaux (local-commercial, bureau, entrepot-industriel…) sont exclus
30 +TYPES = {
31 + "appartement": ("", False),
32 + "condo": ("", False),
33 + "maison": ("Maison", False),
34 + "studio": ("Studio", False),
35 + "loft": ("Loft", False),
36 + "chambre-et-colocation": ("Chambre", False),
37 + "chalet": ("Chalet", False),
38 + "appartement-condo-court-terme": ("", True),
39 + "maison-court-terme": ("Maison", True),
40 + "chambre-court-terme": ("Chambre", True),
41 +}
42 +
43 +_FICHE_RE = re.compile(
44 + r"^https://www\.logisquebec\.com/(" + "|".join(map(re.escape, TYPES)) +
45 + r")-a-louer-([a-z0-9_-]+)-l(\d+)$")
46 +_LOC_RE = re.compile(r"<loc>(.*?)</loc>")
47 +_PHOTO_RE = re.compile(r"https://i\.logisquebec\.com/i-a-louer/\d+/\d+/[^'\"]+")
48 +_DISPO_RE = re.compile(r"Disponible à partir de\s*:?\s*\|\s*([^|]{1,60})")
49 +
50 +DETAIL_LIMIT = int(os.environ.get("LOUKA_LOGISQUEBEC_DETAIL_LIMIT", "1200"))
51 +TTL_DAYS = float(os.environ.get("LOUKA_LOGISQUEBEC_TTL_DAYS", "7"))
52 +MAX_FICHES = int(os.environ.get("LOUKA_LOGISQUEBEC_MAX", "0")) # 0 = tout
53 +
54 +
55 +def _parse_fiche(html: str) -> dict:
56 + """Extrait le payload complet d'une page fiche LogisQuébec."""
57 + out: dict = {}
58 + for node in du.ld_nodes(html):
59 + t = node.get("@type") or ""
60 + if t == "SingleFamilyResidence":
61 + addr = node.get("address") or {}
62 + out["address"] = (addr.get("streetAddress") or "").strip()
63 + out["locality"] = (addr.get("addressLocality") or "").strip()
64 + out["postal_code"] = (addr.get("postalCode") or "").strip()
65 + geo = node.get("geo") or {}
66 + try:
67 + out["lat"] = float(geo.get("latitude"))
68 + out["lng"] = float(geo.get("longitude"))
69 + except (TypeError, ValueError):
70 + pass
71 + if node.get("telephone"):
72 + out["telephone"] = str(node["telephone"]).strip()
73 + rooms = node.get("numberOfRooms")
74 + if isinstance(rooms, (int, float)) and rooms > 0:
75 + out["rooms"] = int(rooms)
76 + elif t == "Product":
77 + out["title"] = (node.get("name") or "").strip()
78 + out["description"] = (node.get("description") or "").strip()[:6000]
79 + offers = node.get("offers") or {}
80 + try:
81 + out["price"] = float(str(offers.get("price")).replace(",", "."))
82 + except (TypeError, ValueError):
83 + pass
84 + if node.get("sku"):
85 + out["sku"] = str(node["sku"])
86 +
87 + photos, seen = [], set()
88 + for u in _PHOTO_RE.findall(html):
89 + if "/thumbnail/" in u or u in seen:
90 + continue
91 + seen.add(u)
92 + photos.append(u)
93 + if photos:
94 + out["images"] = photos
95 +
96 + # « Disponible à partir de : Immédiatement / 1er juillet… » (texte aplati)
97 + flat = du.flatten(html)
98 + m = _DISPO_RE.search(flat)
99 + if m:
100 + out["availability"] = m.group(1).strip(" .")
101 +
102 + # caractéristiques ACTIVES = items possédant un span .caracteristique-texte
103 + # (les items sans texte sont la légende inactive des icônes)
104 + soup = BeautifulSoup(html, "html.parser")
105 + amenities = []
106 + for it in soup.select(".caracteristique-item"):
107 + if it.select_one(".caracteristique-texte") is None:
108 + continue
109 + label = it.get_text(" ", strip=True)
110 + label = re.sub(r"\s+", " ", label)
111 + # le titre est dupliqué dans le texte : « 2 chambres 2 chambres »
112 + half = len(label) // 2
113 + if len(label) % 2 == 1 and label[:half] == label[half + 1:]:
114 + label = label[:half]
115 + if label and label not in amenities:
116 + amenities.append(label)
117 + if amenities:
118 + out["amenities"] = amenities
119 + return out
120 +
121 +
122 +class LogisQuebecConnector(BaseConnector):
123 + source_id = "logisquebec"
124 + request_delay = 0.7
125 +
126 + # -- découverte ------------------------------------------------------------
127 + def _fiche_urls(self) -> list[tuple[str, str, str, str]]:
128 + """(url, type_slug, ville_slug, id) des fiches location résidentielles."""
129 + index = self.get(f"{BASE}/sitemap.xml").text
130 + subs = [u for u in _LOC_RE.findall(index) if "/sitemap/" in u]
131 + out, seen = [], set()
132 + for sub in subs:
133 + try:
134 + xml = self.get(sub).text
135 + except Exception:
136 + continue
137 + for u in _LOC_RE.findall(xml):
138 + m = _FICHE_RE.match(u)
139 + if m and m.group(3) not in seen:
140 + seen.add(m.group(3))
141 + out.append((u, m.group(1), m.group(2), m.group(3)))
142 + return out
143 +
144 + # -- construction ----------------------------------------------------------
145 + def _to_listing(self, url: str, type_slug: str, payload: dict) -> Listing | None:
146 + if not payload or payload.get("gone"):
147 + return None
148 + eid = payload.get("sku") or url.rsplit("-l", 1)[-1]
149 + default_type, court_terme = TYPES[type_slug]
150 +
151 + title = payload.get("title") or ""
152 + unit_type = normalize_unit_type(title)
153 + if not re.match(r"^\d½|^6½\+$", unit_type or ""):
154 + unit_type = ""
155 + if not unit_type and payload.get("rooms"):
156 + n = payload["rooms"]
157 + unit_type = "6½+" if n >= 6 else f"{n}½"
158 + if not unit_type: # « 2 chambres » des caractéristiques
159 + for a in payload.get("amenities") or []:
160 + if re.match(r"^\d+ chambres?$", a):
161 + unit_type = normalize_unit_type(a)
162 + break
163 + if not unit_type:
164 + unit_type = default_type
165 +
166 + locality = payload.get("locality") or ""
167 + m = re.match(r"^(.*?)\s*\((.+)\)\s*$", locality)
168 + sector, city = (m.group(1), m.group(2)) if m else ("", locality)
169 +
170 + price = payload.get("price")
171 + details: dict = {}
172 + if payload.get("postal_code"):
173 + details["Code postal"] = payload["postal_code"]
174 + if payload.get("telephone"):
175 + details["Téléphone"] = payload["telephone"]
176 + if court_terme:
177 + details["Location court terme"] = "oui"
178 +
179 + amenities = payload.get("amenities") or []
180 + lst = Listing(
181 + source=self.source_id,
182 + external_id=str(eid),
183 + url=url,
184 + title=title,
185 + address=payload.get("address") or "",
186 + sector=sector,
187 + city=city,
188 + unit_type=unit_type,
189 + price=price,
190 + price_label=(f"{price:,.0f} $/mois".replace(",", " ") if price else ""),
191 + availability=payload.get("availability") or "",
192 + description=payload.get("description") or "",
193 + amenities=amenities,
194 + details=details,
195 + images=payload.get("images") or [],
196 + lat=payload.get("lat"),
197 + lng=payload.get("lng"),
198 + )
199 + low = [a.lower() for a in amenities]
200 + if any("animaux accept" in a for a in low):
201 + lst.pets = "oui"
202 + if "meublé" in low:
203 + lst.furnished = True
204 + return lst
205 +
206 + def fetch(self) -> list[Listing]:
207 + fiches = self._fiche_urls()
208 + if MAX_FICHES:
209 + fiches = fiches[:MAX_FICHES]
210 + cache = du.TtlDetailCache(self, budget=DETAIL_LIMIT, ttl_days=TTL_DAYS)
211 + out: list[Listing] = []
212 + try:
213 + for url, type_slug, _ville, eid in fiches:
214 + payload = cache.get(eid, url, _parse_fiche)
215 + if payload is None: # jamais visitée + budget épuisé :
216 + continue # captée à une prochaine synchronisation
217 + lst = self._to_listing(url, type_slug, payload)
218 + if lst is not None:
219 + out.append(lst)
220 + finally:
221 + cache.close()
222 + return out
added louka/connectors/louer_ca.py +312 −0
@@ -0,0 +1,312 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/louer_ca.py : Louer.ca — portail locatif 100 % Québec du réseau
5 +# Rentals.ca. Accès par l'API GraphQL interne (https://louer.ca/graphql),
6 +# rétro-conçue : clé publique `rentalsGqlKey` lue dans window.appconf de la
7 +# page d'accueil, mutation `acquireAuthInfo` -> JWT (scalaire JSON contenant
8 +# accessToken ~1 h + refreshToken), en-tête `Authorization: Bearer <access>`.
9 +# La recherche passe par la ville : typeahead -> City.id, puis
10 +# `node(id){... on City{ rentalListings(first,after) }}` (curseur Relay ;
11 +# le champ rentalListings top-level renvoie 0). Le détail `node(id)` fournit
12 +# adresse, description, galerie (images.rentals.ca) et les floorPlans
13 +# (un type/prix par plan) -> une annonce Lou-Ka par plan d'étage.
14 +# ⚠️ Réseau Rentals.ca : CGU interdisant l'extraction sans accord écrit.
15 +# -----------------------------------------------------------------------------
16 +from __future__ import annotations
17 +
18 +import base64
19 +import datetime
20 +import json
21 +import os
22 +import re
23 +import time
24 +
25 +from ..schema import Listing
26 +from .base import BaseConnector
27 +from . import _detailutil as du
28 +
29 +HOME = "https://louer.ca/"
30 +GQL = "https://louer.ca/graphql"
31 +FALLBACK_KEY = "ME8N-J3IX-At86-2yIi"
32 +
33 +# villes québécoises visées (slug interne Louer.ca) ; résolues via typeahead
34 +CITIES = ["montreal", "quebec", "laval", "gatineau", "longueuil", "sherbrooke",
35 + "trois-rivieres", "levis", "terrebonne", "brossard", "saguenay",
36 + "drummondville", "granby", "saint-jean-sur-richelieu", "repentigny"]
37 +
38 +PAGE_SIZE = int(os.environ.get("LOUKA_LOUERCA_PAGE_SIZE", "50"))
39 +DETAIL_LIMIT = int(os.environ.get("LOUKA_LOUERCA_DETAIL_LIMIT", "600"))
40 +TTL_DAYS = float(os.environ.get("LOUKA_LOUERCA_TTL_DAYS", "5"))
41 +MAX_PER_CITY = int(os.environ.get("LOUKA_LOUERCA_MAX_PER_CITY", "0")) # 0 = tout
42 +
43 +# amenity (catégorie, valeur) -> libellé FR affichable
44 +_AMENITY_FR = {
45 + "laundry-facilities": "Buanderie", "in-suite-laundry": "Laveuse/sécheuse dans l'unité",
46 + "security-on-site": "Sécurité sur place", "storage-lockers": "Espace de rangement",
47 + "swimming-pool": "Piscine", "gym": "Salle d'entraînement", "elevator": "Ascenseur",
48 + "sauna": "Sauna", "on-site-staff": "Personnel sur place", "stove": "Cuisinière",
49 + "fridge": "Réfrigérateur", "balcony": "Balcon", "microwave": "Micro-ondes",
50 + "dishwasher": "Lave-vaisselle", "air-conditioning": "Air climatisé",
51 + "individual-thermostats": "Thermostats individuels", "heating": "Chauffage inclus",
52 + "water": "Eau incluse", "hydro-electricity": "Électricité incluse",
53 + "public-transit": "Transport en commun à proximité", "parking": "Stationnement",
54 + "no-smoking-allowed": "Non-fumeur",
55 +}
56 +
57 +
58 +class LouerCaConnector(BaseConnector):
59 + source_id = "louer_ca"
60 + request_delay = 0.5
61 +
62 + def __init__(self) -> None:
63 + super().__init__()
64 + self.session.headers.update({
65 + "Content-Type": "application/json",
66 + "Origin": "https://louer.ca",
67 + "Referer": "https://louer.ca/",
68 + })
69 + self._token = ""
70 + self._token_time = 0.0
71 + self._api_key = FALLBACK_KEY
72 +
73 + # -- auth ------------------------------------------------------------------
74 + def _ensure_token(self) -> None:
75 + if self._token and time.time() - self._token_time < 2700: # ~45 min
76 + return
77 + try:
78 + home = self.get(HOME).text
79 + m = re.search(r'"rentalsGqlKey":\s*"([^"]+)"', home)
80 + if m:
81 + self._api_key = m.group(1)
82 + except Exception:
83 + pass
84 + data = self._gql(
85 + "mutation($k:String!){acquireAuthInfo(credentials:{apiKey:$k}){jwt status}}",
86 + {"k": self._api_key}, auth=False)
87 + auth = (data or {}).get("acquireAuthInfo") or {}
88 + jwt = auth.get("jwt")
89 + if isinstance(jwt, str) and jwt.startswith("{"):
90 + jwt = json.loads(jwt)
91 + token = jwt.get("accessToken") if isinstance(jwt, dict) else jwt
92 + if not token:
93 + raise RuntimeError("Louer.ca: handshake JWT échoué")
94 + self._token = token
95 + self._token_time = time.time()
96 +
97 + def _gql(self, query: str, variables: dict, auth: bool = True) -> dict:
98 + headers = {}
99 + if auth:
100 + self._ensure_token()
101 + headers["Authorization"] = f"Bearer {self._token}"
102 + resp = self.post(GQL, data=json.dumps({"query": query,
103 + "variables": variables}),
104 + headers=headers)
105 + payload = resp.json()
106 + if payload.get("errors"):
107 + msg = payload["errors"][0].get("message", "")
108 + if "AUTH" in msg.upper() and auth: # jeton expiré : on réessaie
109 + self._token = ""
110 + self._ensure_token()
111 + resp = self.post(GQL, data=json.dumps({"query": query,
112 + "variables": variables}),
113 + headers={"Authorization": f"Bearer {self._token}"})
114 + payload = resp.json()
115 + return payload.get("data") or {}
116 +
117 + # -- découverte ------------------------------------------------------------
118 + def _city_id(self, slug: str) -> str | None:
119 + d = self._gql(
120 + "query($v:String!){typeahead(value:$v){nodeType node{id ... on City"
121 + "{name path regionCode listingCount}}}}", {"v": slug.replace("-", " ")})
122 + for it in d.get("typeahead") or []:
123 + node = it.get("node") or {}
124 + if node.get("path") == slug and node.get("regionCode") == "QC":
125 + return node.get("id")
126 + # à défaut, première ville québécoise proposée
127 + for it in d.get("typeahead") or []:
128 + node = it.get("node") or {}
129 + if node.get("id") and node.get("regionCode") == "QC":
130 + return node.get("id")
131 + return None
132 +
133 + _LIST_FRAG = ("id name path location rentRange bedsRange bathsRange "
134 + "sizeRange type furnished petOptions amenities verified "
135 + "created modified")
136 +
137 + def _city_listings(self, city_id: str) -> list[dict]:
138 + query = ("query($id:ID!,$first:PositiveInt!,$after:String){node(id:$id)"
139 + "{... on City{rentalListings(first:$first,after:$after){"
140 + "meta{totalCount} pageInfo{hasNextPage endCursor} "
141 + "edges{node{" + self._LIST_FRAG + "}}}}}}")
142 + out, after = [], None
143 + while True:
144 + d = self._gql(query, {"id": city_id, "first": PAGE_SIZE,
145 + "after": after})
146 + rl = ((d.get("node") or {}).get("rentalListings")) or {}
147 + edges = rl.get("edges") or []
148 + out.extend(e["node"] for e in edges if e.get("node"))
149 + info = rl.get("pageInfo") or {}
150 + if not info.get("hasNextPage") or not edges:
151 + break
152 + if MAX_PER_CITY and len(out) >= MAX_PER_CITY:
153 + break
154 + after = info.get("endCursor")
155 + return out
156 +
157 + _DETAIL_FRAG = ("id name path location "
158 + "address{city{name regionCode} neighbourhood{name} "
159 + "postalCode street} description{plain} "
160 + "imagesCount images{scales} "
161 + "floorPlans{beds baths rent size availability furnished}")
162 +
163 + def _detail(self, gid: str) -> dict:
164 + d = self._gql("query($id:ID!){node(id:$id){... on RentalListing{"
165 + + self._DETAIL_FRAG + "}}}", {"id": gid})
166 + return d.get("node") or {}
167 +
168 + # -- construction ----------------------------------------------------------
169 + @staticmethod
170 + def _unit_type(beds) -> str:
171 + try:
172 + b = float(beds)
173 + except (TypeError, ValueError):
174 + return ""
175 + if b <= 0:
176 + return "Studio"
177 + n = int(b) + 2 # chambres -> pièces et demie
178 + return "6½+" if n >= 6 else f"{n}½"
179 +
180 + @staticmethod
181 + def _amenities(pairs) -> list[str]:
182 + out = []
183 + for pair in pairs or []:
184 + val = pair[1] if isinstance(pair, list) and len(pair) > 1 else None
185 + label = _AMENITY_FR.get(val)
186 + if label and label not in out:
187 + out.append(label)
188 + return out
189 +
190 + def _images(self, node: dict) -> list[str]:
191 + imgs = []
192 + for im in node.get("images") or []:
193 + scales = im.get("scales")
194 + if isinstance(scales, str):
195 + try:
196 + scales = json.loads(scales)
197 + except ValueError:
198 + scales = []
199 + best = ""
200 + for sc in scales or []:
201 + if sc.get("name") in ("large", "medium") and sc.get("url"):
202 + best = sc["url"]
203 + if sc["name"] == "large":
204 + break
205 + if not best and scales:
206 + best = scales[0].get("url", "")
207 + if best and best not in imgs:
208 + imgs.append(best)
209 + return imgs
210 +
211 + @staticmethod
212 + def _numeric_id(gid: str) -> str:
213 + """« cmVudGFsbGlzdGluZzoxMTMyMTE2 » -> « 1132116 » (rentallisting:1132116)."""
214 + try:
215 + decoded = base64.b64decode(gid + "==").decode("utf-8", "ignore")
216 + m = re.search(r"(\d+)", decoded)
217 + if m:
218 + return m.group(1)
219 + except Exception:
220 + pass
221 + m = re.search(r"(\d+)", gid)
222 + return m.group(1) if m else gid
223 +
224 + def fetch(self) -> list[Listing]:
225 + # le « détail » est une requête GraphQL, pas une page HTML : le
226 + # fetch_html du cache reçoit le gid et le renvoie tel quel, parse_fn
227 + # exécute la requête GraphQL.
228 + cache = du.TtlDetailCache(self, budget=DETAIL_LIMIT, ttl_days=TTL_DAYS,
229 + key="v1", fetch_html=lambda gid: gid)
230 + out: list[Listing] = []
231 + today = datetime.date.today().isoformat()
232 + try:
233 + for slug in CITIES:
234 + cid = self._city_id(slug)
235 + if not cid:
236 + continue
237 + for card in self._city_listings(cid):
238 + gid = card.get("id")
239 + if not gid:
240 + continue
241 + node = cache.get(gid, gid,
242 + lambda g: self._detail(g)) or {}
243 + addr = node.get("address") or {}
244 + city = (addr.get("city") or {}).get("name") or slug.title()
245 + sector = (addr.get("neighbourhood") or {}).get("name") or ""
246 + loc = card.get("location") or []
247 + lng, lat = (loc + [None, None])[:2]
248 + base_desc = ((node.get("description") or {}).get("plain")
249 + or "")[:6000]
250 + images = self._images(node)
251 + amenities = self._amenities(card.get("amenities"))
252 + common = dict(
253 + source=self.source_id, url=f"https://louer.ca/{card.get('path','')}",
254 + address=addr.get("street") or "", sector=sector, city=city,
255 + description=base_desc, amenities=amenities, images=images,
256 + lat=lat, lng=lng,
257 + )
258 + if addr.get("postalCode"):
259 + common["details"] = {"Code postal": addr["postalCode"]}
260 +
261 + plans = node.get("floorPlans") or []
262 + if not plans:
263 + # pas de plan détaillé : une annonce « à partir de »
264 + rng = card.get("rentRange") or []
265 + price = rng[0] if rng else None
266 + beds = (card.get("bedsRange") or [None])[0]
267 + out.append(self._mk(common, gid, "", price,
268 + self._unit_type(beds), None, today,
269 + price_from=bool(rng)))
270 + continue
271 + for i, fp in enumerate(plans):
272 + avail = fp.get("availability") or {}
273 + adate = "now" if avail.get("now") else (
274 + avail.get("date") or "")[:10] or None
275 + if adate and adate != "now" and adate <= today:
276 + adate = "now"
277 + lst = self._mk(common, gid, f"-{i}", fp.get("rent"),
278 + self._unit_type(fp.get("beds")),
279 + fp.get("size"), today, adate=adate)
280 + if fp.get("furnished") == "yes":
281 + lst.furnished = True
282 + out.append(lst)
283 + finally:
284 + cache.close()
285 + return out
286 +
287 + def _mk(self, common: dict, gid: str, suffix: str, price, unit_type,
288 + size, today, adate=None, price_from=False) -> Listing:
289 + num = self._numeric_id(gid)
290 + p = None
291 + try:
292 + p = float(price) if price is not None else None
293 + except (TypeError, ValueError):
294 + p = None
295 + details = dict(common.get("details") or {})
296 + if price_from:
297 + details["price_from"] = True
298 + lst = Listing(
299 + **{k: v for k, v in common.items() if k != "details"},
300 + external_id=f"{num}{suffix}",
301 + title=common.get("address") or "Logement à louer",
302 + unit_type=unit_type,
303 + price=p,
304 + price_label=(("À partir de " if price_from else "")
305 + + (f"{p:,.0f} $/mois".replace(",", " ") if p else "")),
306 + area_sqft=float(size) if size else None,
307 + availability_date=adate,
308 + availability=("Libre immédiatement" if adate == "now"
309 + else f"Libre le {adate}" if adate else ""),
310 + details=details,
311 + )
312 + return lst
added louka/connectors/omhm.py +86 −0
@@ -0,0 +1,86 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/omhm.py : OMHM — Office municipal d'habitation de Montréal.
5 +# Le HLM classique fonctionne par liste d'attente (non agrégeable) ; SEULE
6 +# la page « logements disponibles » (résidences abordables ENHARMONIE pour
7 +# aînés autonomes 60 ans +) liste de vraies unités. Site Drupal SSR, aucun
8 +# anti-bot. Chaque unité = un <article class="node--type-logement-disponible">
9 +# avec adresse civique complète et grille de prix par type (« 3 ½ entre
10 +# 1 357 $ et 1 497 $ »). Volume faible (~5) mais différenciateur social
11 +# (filtre « logement abordable » de Lou-Ka).
12 +# -----------------------------------------------------------------------------
13 +from __future__ import annotations
14 +
15 +import re
16 +
17 +from bs4 import BeautifulSoup
18 +
19 +from ..schema import Listing, normalize_unit_type
20 +from .base import BaseConnector
21 +
22 +URL = "https://www.omhm.qc.ca/fr/soumettre-une-demande/logements-disponibles"
23 +
24 +_POSTAL_RE = re.compile(r"[A-Z]\d[A-Z]\s?\d[A-Z]\d")
25 +# « 3 ½ entre 1 357 $ et 1 497 $ » ou « studio 730 $ à 925 $ » ou « 4 ½ à 2 167 $ »
26 +_UNIT_PRICE_RE = re.compile(
27 + r"(studio|\d\s*½|\d\s*1/2)\s*(?:entre|à|de|:)?\s*"
28 + r"([\d\s]+)\s*\$(?:\s*(?:et|à|-)\s*([\d\s]+)\s*\$)?", re.I)
29 +
30 +
31 +class OmhmConnector(BaseConnector):
32 + source_id = "omhm"
33 + request_delay = 0.8
34 +
35 + def fetch(self) -> list[Listing]:
36 + soup = BeautifulSoup(self.get(URL).text, "html.parser")
37 + out: list[Listing] = []
38 + for art in soup.select("article.node--type-logement-disponible"):
39 + title_el = art.find(re.compile("^h[2-4]$"))
40 + name = title_el.get_text(" ", strip=True) if title_el else ""
41 + full = re.sub(r"\s+", " ", art.get_text(" | ", strip=True))
42 +
43 + # adresse : « 12100, rue Rodolphe-Forget | Montréal | H1E 7S2 »
44 + m = re.search(r"(\d+[^|]+?)\s*\|\s*([A-ZÉÈÀ][^|]+?)\s*\|\s*"
45 + r"(" + _POSTAL_RE.pattern + r")", full)
46 + address = city = postal = ""
47 + if m:
48 + address = m.group(1).strip(" ,")
49 + city = m.group(2).strip()
50 + postal = m.group(3).strip()
51 +
52 + slug = re.sub(r"[^a-z0-9]+", "-",
53 + (name or address).lower()).strip("-") or "omhm"
54 + # une annonce par type d'unité offert dans la résidence
55 + seen = set()
56 + for um in _UNIT_PRICE_RE.finditer(full):
57 + unit_type = normalize_unit_type(um.group(1))
58 + if unit_type in seen:
59 + continue
60 + seen.add(unit_type)
61 + try:
62 + price = float(um.group(2).replace(" ", ""))
63 + except ValueError:
64 + continue
65 + pmax = um.group(3)
66 + label = (f"Entre {price:,.0f} $ et {float(pmax.replace(' ','')):,.0f} $"
67 + .replace(",", " ") if pmax
68 + else f"À partir de {price:,.0f} $".replace(",", " "))
69 + out.append(Listing(
70 + source=self.source_id,
71 + external_id=f"{slug}-{unit_type}",
72 + url=URL,
73 + title=f"{name} — {unit_type}" if name else unit_type,
74 + address=address,
75 + city=city or "Montréal",
76 + unit_type=unit_type,
77 + price=price,
78 + price_label=label,
79 + availability="Disponible",
80 + details={"Code postal": postal,
81 + "Logement abordable": "oui",
82 + "Résidence pour aînés": "oui"} if postal else
83 + {"Logement abordable": "oui",
84 + "Résidence pour aînés": "oui"},
85 + ))
86 + return out
added louka/connectors/roomies.py +182 −0
@@ -0,0 +1,182 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/roomies.py : Roomies.ca — chambres et colocation (segment que les
5 +# gestionnaires ne couvrent pas). Site Laravel/Livewire server-rendered,
6 +# robots.txt entièrement ouvert, aucun anti-bot (curl simple = 200).
7 +# Découverte par les pages villes `/{ville}-qc?page=N` (les sitemaps
8 +# `sitemap/rooms.xml` répondent 504 par intermittence — non fiables).
9 +# Fiche `/rooms/<id>` : JSON-LD `Room` complet (offers.price mensuel,
10 +# availabilityStarts, lat/lng, petsAllowed, chambres/SDB de l'appartement,
11 +# PostalAddress, amenityFeature) + description intégrale dans un div
12 +# `prose` + galerie Cloudinary. On ignore les profils de chercheurs de
13 +# colocataires (/profiles) : seules les chambres offertes sont des annonces.
14 +# -----------------------------------------------------------------------------
15 +from __future__ import annotations
16 +
17 +import datetime
18 +import os
19 +import re
20 +
21 +from bs4 import BeautifulSoup
22 +
23 +from ..schema import Listing
24 +from .base import BaseConnector
25 +from . import _detailutil as du
26 +
27 +BASE = "https://www.roomies.ca"
28 +
29 +# villes québécoises servies (slug -qc) ; une page absente (404) est ignorée
30 +CITIES = {
31 + "montreal-qc": "Montréal", "quebec-qc": "Québec", "laval-qc": "Laval",
32 + "gatineau-qc": "Gatineau", "longueuil-qc": "Longueuil",
33 + "sherbrooke-qc": "Sherbrooke", "trois-rivieres-qc": "Trois-Rivières",
34 + "levis-qc": "Lévis", "brossard-qc": "Brossard",
35 + "terrebonne-qc": "Terrebonne", "repentigny-qc": "Repentigny",
36 + "saint-jean-sur-richelieu-qc": "Saint-Jean-sur-Richelieu",
37 + "drummondville-qc": "Drummondville", "granby-qc": "Granby",
38 + "saguenay-qc": "Saguenay",
39 +}
40 +MAX_PAGES = int(os.environ.get("LOUKA_ROOMIES_MAX_PAGES", "20"))
41 +DETAIL_LIMIT = int(os.environ.get("LOUKA_ROOMIES_DETAIL_LIMIT", "250"))
42 +TTL_DAYS = float(os.environ.get("LOUKA_ROOMIES_TTL_DAYS", "5"))
43 +
44 +_ROOM_LINK_RE = re.compile(r'href="(?:https://www\.roomies\.ca)?(/rooms/(\d+))"')
45 +_PHOTO_RE = re.compile(
46 + r'https://cloudinary\.roomies\.pics/image/upload/[^"\\\s]+?/(\w+)')
47 +
48 +
49 +def _parse_fiche(html: str) -> dict:
50 + out: dict = {}
51 + for node in du.ld_nodes(html):
52 + if node.get("@type") != "Room":
53 + continue
54 + name = str(node.get("name") or "")
55 + out["type_label"] = name.split("|")[0].strip() # « Furnished room in… »
56 + addr = node.get("address") or {}
57 + out["address"] = (addr.get("streetAddress") or "").strip()
58 + out["postal_code"] = (addr.get("postalCode") or "").strip()
59 + out["lat"] = node.get("latitude")
60 + out["lng"] = node.get("longitude")
61 + if node.get("petsAllowed") is not None:
62 + out["pets"] = "oui" if node["petsAllowed"] else "non"
63 + details = {}
64 + if node.get("numberOfBedrooms"):
65 + details["Chambres (logement)"] = str(node["numberOfBedrooms"])
66 + if node.get("numberOfBathroomsTotal"):
67 + details["Salles de bain"] = str(node["numberOfBathroomsTotal"])
68 + if details:
69 + out["details"] = details
70 + feats = node.get("amenityFeature") or []
71 + amenities = []
72 + for f in feats:
73 + if isinstance(f, dict) and f.get("name") and f.get("value") is not False:
74 + amenities.append(str(f["name"]))
75 + if f["name"] == "Furnished":
76 + out["furnished"] = True
77 + if amenities:
78 + out["amenities"] = amenities
79 + offers = node.get("offers") or {}
80 + try:
81 + out["price"] = float(offers.get("price"))
82 + except (TypeError, ValueError):
83 + pass
84 + starts = str(offers.get("availabilityStarts") or "")[:10]
85 + if starts:
86 + out["availability_date"] = ("now" if starts
87 + <= datetime.date.today().isoformat()
88 + else starts)
89 + if node.get("description"):
90 + out["description"] = str(node["description"]).strip()[:6000]
91 +
92 + # description intégrale (le JSON-LD est tronqué à ~250 caractères)
93 + soup = BeautifulSoup(html, "html.parser")
94 + prose = soup.find("div", class_=lambda c: c and "prose" in c
95 + and "break-words" in c)
96 + if prose:
97 + full = prose.get_text("\n", strip=True)
98 + if len(full) > len(out.get("description") or ""):
99 + out["description"] = full[:6000]
100 +
101 + # galerie Cloudinary : dédupliquée par identifiant public, format standard
102 + photos, seen = [], set()
103 + for pid in _PHOTO_RE.findall(html):
104 + if pid in seen or len(pid) < 12:
105 + continue
106 + seen.add(pid)
107 + photos.append("https://cloudinary.roomies.pics/image/upload/"
108 + f"c_fill,f_jpg,fl_lossy,g_auto,h_512,q_auto:good,w_896/{pid}")
109 + if photos:
110 + out["images"] = photos
111 + return out
112 +
113 +
114 +class RoomiesConnector(BaseConnector):
115 + source_id = "roomies"
116 + request_delay = 0.8
117 +
118 + def _city_room_ids(self, slug: str) -> list[str]:
119 + ids, page = [], 1
120 + while page <= MAX_PAGES:
121 + url = f"{BASE}/{slug}" + (f"?page={page}" if page > 1 else "")
122 + try:
123 + html = self.get(url).text
124 + except Exception:
125 + break
126 + found = [rid for _, rid in _ROOM_LINK_RE.findall(html)]
127 + fresh = [r for r in found if r not in ids]
128 + if not fresh:
129 + break
130 + ids.extend(fresh)
131 + page += 1
132 + return ids
133 +
134 + def fetch(self) -> list[Listing]:
135 + cache = du.TtlDetailCache(self, budget=DETAIL_LIMIT, ttl_days=TTL_DAYS)
136 + out: dict[str, Listing] = {}
137 + try:
138 + for slug, city in CITIES.items():
139 + for rid in self._city_room_ids(slug):
140 + if rid in out:
141 + continue
142 + url = f"{BASE}/rooms/{rid}"
143 + payload = cache.get(rid, url, _parse_fiche)
144 + if not payload or payload.get("gone"):
145 + continue
146 + price = payload.get("price")
147 + avail = payload.get("availability_date")
148 + type_label = (payload.get("type_label") or "").lower()
149 + unit_type = ("Studio" if "studio" in type_label
150 + and "shared" not in type_label else "Chambre")
151 + lst = Listing(
152 + source=self.source_id,
153 + external_id=rid,
154 + url=url,
155 + title=(payload.get("type_label") or "Chambre")
156 + + (f" — {payload['address']}" if payload.get("address") else ""),
157 + address=payload.get("address") or "",
158 + city=city,
159 + unit_type=unit_type,
160 + price=price,
161 + price_label=(f"{price:,.0f} $/mois".replace(",", " ")
162 + if price else ""),
163 + availability_date=avail,
164 + availability=("Libre immédiatement" if avail == "now"
165 + else f"Libre le {avail}" if avail else ""),
166 + description=payload.get("description") or "",
167 + amenities=payload.get("amenities") or [],
168 + details=payload.get("details") or {},
169 + images=payload.get("images") or [],
170 + lat=payload.get("lat"),
171 + lng=payload.get("lng"),
172 + )
173 + if payload.get("postal_code"):
174 + lst.details["Code postal"] = payload["postal_code"]
175 + if payload.get("pets"):
176 + lst.pets = payload["pets"]
177 + if payload.get("furnished"):
178 + lst.furnished = True
179 + out[rid] = lst
180 + finally:
181 + cache.close()
182 + return list(out.values())
modified louka/db.py +2 −0
@@ -156,6 +156,8 @@ _MIGRATIONS = {
156 156 "miss_count": "INTEGER DEFAULT 0",
157 157 "dauid": "TEXT", # aire de diffusion 2021 (stats de quartier)
158 158 "digest": "TEXT", # JSON louka/textmine.py (description structurée)
159 + "dup_of": "TEXT", # uid de l'annonce canonique si doublon inter-sources
160 + "dup_sources": "TEXT", # JSON : autres sources où l'annonce est publiée
159 161 },
160 162 "sync_log": {
161 163 "stats": "TEXT",
added louka/dedup.py +273 −0
@@ -0,0 +1,273 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# dedup.py : déduplication INTER-SOURCES — une même unité publiée sur plusieurs
5 +# plateformes (ex. un gestionnaire agrégé en direct ET via LogisQuébec,
6 +# ou un particulier sur Kijiji ET Facebook) ne doit apparaître qu'une
7 +# fois côté site.
8 +#
9 +# Principe (recalculé intégralement à chaque passe, idempotent) :
10 +# 1. Blocage : regrouper les annonces actives par proximité (grille lat/lng
11 +# ~110 m + cases voisines) OU par clé d'adresse normalisée (n° civique +
12 +# rue sans type de voie + ville).
13 +# 2. Confirmation d'une paire : même type d'unité (si connu des deux côtés),
14 +# prix à ±4 % (si connu), et signal géographique/adresse concordant.
15 +# 3. Résolution : le membre de la source la PLUS autoritaire devient canonique
16 +# (dup_of=NULL) ; les autres pointent vers lui (dup_of=uid canonique). Le
17 +# canonique liste les autres plateformes dans dup_sources (badge « aussi
18 +# publiée sur… »). Rien n'est supprimé.
19 +#
20 +# Le web (web.py) n'affiche que les annonces canoniques : `WHERE dup_of IS NULL`.
21 +# Appelé par ingest.watch() APRÈS le géocodage (les coordonnées sont l'ingrédient
22 +# principal du blocage).
23 +# -----------------------------------------------------------------------------
24 +from __future__ import annotations
25 +
26 +import json
27 +
28 +from . import db
29 +from .normalize import strip_accents
30 +
31 +# Autorité des sources : plus l'indice est BAS, plus la source est prioritaire
32 +# comme canonique. 0 = gestionnaire/courtier en direct (donnée d'origine),
33 +# 10 = portail agrégateur, 20 = petites annonces / particuliers.
34 +PORTALS = {"logisquebec", "louer_ca", "rentals_ca", "zumper", "padmapper",
35 + "realtor", "mercini"}
36 +CLASSIFIEDS = {"kijiji", "lespac", "fb_marketplace", "bons_locataires",
37 + "roomies", "duproprio", "oklouer"}
38 +
39 +
40 +def _authority(source: str) -> int:
41 + if source in CLASSIFIEDS:
42 + return 20
43 + if source in PORTALS:
44 + return 10
45 + return 0 # tout connecteur de gestionnaire/courtier en direct
46 +
47 +
48 +# types de voie retirés pour comparer les rues (fr + en)
49 +_STREET_TYPES = {
50 + "rue", "avenue", "av", "ave", "boulevard", "boul", "bd", "chemin", "ch",
51 + "montee", "montée", "rang", "place", "pl", "cote", "côte", "impasse",
52 + "terrasse", "tsse", "allee", "allée", "croissant", "crois", "cours",
53 + "street", "st", "road", "rd", "drive", "dr", "lane", "ln", "court", "crt",
54 + "way", "circle", "blvd", "de", "du", "des", "la", "le", "les", "l", "d",
55 +}
56 +
57 +
58 +_UNIT_MARKERS = {"app", "apt", "appartement", "unit", "unite", "suite", "bureau"}
59 +# points cardinaux unifiés FR/EN (« Sherbrooke Ouest » == « Sherbrooke West »)
60 +_DIRECTIONS = {"ouest": "o", "west": "o", "w": "o", "est": "e", "east": "e",
61 + "e": "e", "nord": "n", "north": "n", "n": "n", "sud": "s",
62 + "south": "s", "s": "s"}
63 +# marqueurs de fin de rue : province/pays (PAS les noms de ville — « Sherbrooke »
64 +# est aussi un nom de rue fréquent ; le nom de la ville de l'annonce est géré
65 +# séparément)
66 +_ADDR_STOP = {"qc", "quebec", "canada", "que", "ca"}
67 +
68 +
69 +def _parse_address(address: str, city: str) -> tuple[str | None, str | None]:
70 + """Retourne (clé d'adresse, numéro d'unité) à partir d'une adresse civique.
71 +
72 + - clé = « civique|rue-significative|ville » sans accents ni type de voie,
73 + points cardinaux unifiés FR/EN ; None si aucun numéro civique fiable.
74 + - numéro d'unité (app/apt/#/suite ou nombre surnuméraire en tête) : sert à
75 + NE PAS fusionner deux logements distincts au même immeuble. None si absent.
76 +
77 + Gère « 304 4557 Rue Sherbrooke » (304 = unité, 4557 = civique), « 12100,
78 + rue Rodolphe-Forget » (virgule après le civique) et « 4557 Sherbrooke O ».
79 + """
80 + a = strip_accents((address or "").lower())
81 + toks = [t for t in _re_split(a) if t] # virgules -> séparateurs
82 + if not toks:
83 + return None, None
84 +
85 + unit = None
86 + for i, t in enumerate(toks): # marqueur explicite « app 304 »
87 + if t in _UNIT_MARKERS and i + 1 < len(toks) and toks[i + 1].isdigit():
88 + unit = toks[i + 1]
89 +
90 + lead_nums = [] # nombres en tête
91 + j = 0
92 + while j < len(toks) and toks[j].isdigit():
93 + lead_nums.append(toks[j])
94 + j += 1
95 + if not lead_nums:
96 + return None, unit
97 + civic = lead_nums[-1] # le nombre collé à la rue
98 + if len(lead_nums) > 1 and unit is None:
99 + unit = lead_nums[0]
100 +
101 + c = strip_accents((city or "").lower()).strip()
102 + city_toks = set(_re_split(c))
103 + words = []
104 + for t in toks[j:]:
105 + if t in _ADDR_STOP or t.isdigit(): # province/pays/code postal
106 + break
107 + if words and t in city_toks: # ville de l'annonce répétée
108 + break
109 + if t in _STREET_TYPES or t in _UNIT_MARKERS:
110 + continue
111 + words.append(_DIRECTIONS.get(t, t))
112 + if not words:
113 + return None, unit
114 + return f"{civic}|{'-'.join(words)}|{c}", unit
115 +
116 +
117 +def _re_split(s: str) -> list[str]:
118 + out, cur = [], []
119 + for ch in s:
120 + if ch.isalnum():
121 + cur.append(ch)
122 + elif cur:
123 + out.append("".join(cur))
124 + cur = []
125 + if cur:
126 + out.append("".join(cur))
127 + return out
128 +
129 +
130 +PRICE_TOL = 0.04 # ±4 % sur le loyer
131 +
132 +
133 +def _same_listing(x: dict, y: dict) -> bool:
134 + """Confirme que deux annonces (de sources différentes) sont la MÊME unité.
135 +
136 + Précision d'abord : on préfère afficher un doublon plutôt que masquer par
137 + erreur un logement distinct. La confirmation exige une clé d'adresse
138 + identique (même numéro civique + même rue + même ville) — la proximité GPS
139 + seule est rejetée car elle fusionnait des immeubles voisins différents.
140 + """
141 + # clé d'adresse civique identique : condition nécessaire
142 + if not (x["akey"] and y["akey"] and x["akey"] == y["akey"]):
143 + return False
144 + # numéros d'unité connus et DIFFÉRENTS -> logements distincts du même
145 + # immeuble : ne pas fusionner
146 + if x["unit"] and y["unit"] and x["unit"] != y["unit"]:
147 + return False
148 + # type d'unité : s'il est connu des deux côtés, il doit concorder
149 + ux, uy = x["unit_type"], y["unit_type"]
150 + if ux and uy and ux != uy:
151 + return False
152 + # prix : s'il est connu des deux côtés, tolérance ±4 %
153 + px, py = x["price"], y["price"]
154 + if px and py and abs(px - py) > PRICE_TOL * max(px, py):
155 + return False
156 + # à ce stade : même adresse civique, unité/type/prix compatibles.
157 + # exiger un second signal concordant (type OU prix) pour éviter de fusionner
158 + # deux logements différents partageant seulement l'adresse (plex, tour)
159 + if ux and uy and ux == uy:
160 + return True
161 + if px and py and abs(px - py) <= PRICE_TOL * max(px, py):
162 + return True
163 + if x["unit"] and y["unit"] and x["unit"] == y["unit"]:
164 + return True
165 + return False
166 +
167 +
168 +def run(con=None) -> dict:
169 + """Recalcule les groupes de doublons inter-sources. Idempotent."""
170 + own = con is None
171 + if own:
172 + con = db.connect()
173 + rows = con.execute(
174 + "SELECT uid, source, external_id, unit_type, price, address, city,"
175 + " lat, lng, images, description FROM listings WHERE active=1").fetchall()
176 +
177 + items = []
178 + by_addr: dict[str, list[int]] = {}
179 + for r in rows:
180 + akey, unit = _parse_address(r["address"], r["city"])
181 + it = {
182 + "uid": r["uid"], "source": r["source"], "unit_type": r["unit_type"] or "",
183 + "price": r["price"], "akey": akey, "unit": unit,
184 + "auth": _authority(r["source"]),
185 + "richness": len(r["description"] or "") + 50 * _img_count(r["images"]),
186 + }
187 + idx = len(items)
188 + items.append(it)
189 + if akey:
190 + by_addr.setdefault(akey, []).append(idx)
191 +
192 + # union-find sur les paires confirmées ; un groupe ne peut PAS contenir deux
193 + # annonces de la même source (une source ne publie pas deux fois la même
194 + # unité) : cette contrainte empêche l'enchaînement transitif qui fusionnait
195 + # des logements distincts d'un même immeuble.
196 + parent = list(range(len(items)))
197 + srcset: list[set[str]] = [{it["source"]} for it in items]
198 +
199 + def find(i):
200 + while parent[i] != i:
201 + parent[i] = parent[parent[i]]
202 + i = parent[i]
203 + return i
204 +
205 + def union(i, j) -> bool:
206 + ri, rj = find(i), find(j)
207 + if ri == rj:
208 + return False
209 + if srcset[ri] & srcset[rj]: # sources en conflit : refuser la fusion
210 + return False
211 + parent[ri] = rj
212 + srcset[rj] |= srcset[ri]
213 + return True
214 +
215 + def consider(cand: list[int]):
216 + n = len(cand)
217 + if n < 2 or n > 400: # garde-fou : blocs trop denses ignorés
218 + return
219 + for a in range(n):
220 + for b in range(a + 1, n):
221 + ia, ib = cand[a], cand[b]
222 + if items[ia]["source"] == items[ib]["source"]:
223 + continue # jamais dédupliquer au sein d'une source
224 + if find(ia) == find(ib):
225 + continue
226 + if _same_listing(items[ia], items[ib]):
227 + union(ia, ib)
228 +
229 + for group in by_addr.values():
230 + consider(group)
231 +
232 + # regrouper et choisir le canonique (autorité, puis richesse, puis uid)
233 + groups: dict[int, list[int]] = {}
234 + for i in range(len(items)):
235 + groups.setdefault(find(i), []).append(i)
236 +
237 + updates = []
238 + n_dupes = n_groups = 0
239 + for members in groups.values():
240 + if len(members) < 2:
241 + updates.append((None, None, items[members[0]]["uid"]))
242 + continue
243 + members.sort(key=lambda i: (items[i]["auth"], -items[i]["richness"],
244 + items[i]["uid"]))
245 + canon = members[0]
246 + canon_uid = items[canon]["uid"]
247 + other_sources = sorted({items[i]["source"] for i in members[1:]})
248 + updates.append((None, json.dumps(other_sources, ensure_ascii=False),
249 + canon_uid))
250 + for i in members[1:]:
251 + updates.append((canon_uid, None, items[i]["uid"]))
252 + n_dupes += 1
253 + n_groups += 1
254 +
255 + con.executemany(
256 + "UPDATE listings SET dup_of=?, dup_sources=? WHERE uid=?", updates)
257 + con.commit()
258 + if own:
259 + con.close()
260 + stats = {"groups": n_groups, "duplicates_hidden": n_dupes,
261 + "listings_scanned": len(items)}
262 + return stats
263 +
264 +
265 +def _img_count(images_json: str | None) -> int:
266 + try:
267 + return len(json.loads(images_json or "[]"))
268 + except (ValueError, TypeError):
269 + return 0
270 +
271 +
272 +if __name__ == "__main__":
273 + print(run())
modified louka/ingest.py +6 −0
@@ -75,6 +75,12 @@ def watch(interval_seconds: int = 3600) -> None:
75 75 quartier.enrich()
76 76 except Exception as exc:
77 77 print(f"[lou-ka] quartier: erreur non bloquante: {exc}", file=sys.stderr)
78 + try: # déduplication inter-sources (APRÈS géocodage : coords requises)
79 + from . import dedup
80 + stats = dedup.run()
81 + print(f"[lou-ka] dedup: {stats}")
82 + except Exception as exc:
83 + print(f"[lou-ka] dedup: erreur non bloquante: {exc}", file=sys.stderr)
78 84 print(f"[lou-ka] prochaine synchronisation dans {interval_seconds}s")
79 85 time.sleep(interval_seconds)
80 86
modified louka/web.py +18 −7
@@ -51,6 +51,12 @@ def _row_to_dict(row) -> dict:
51 51 d["amenities"] = json.loads(d.get("amenities") or "[]")
52 52 d["images"] = json.loads(d.get("images") or "[]")
53 53 d["details"] = json.loads(d.get("details") or "{}")
54 + # autres plateformes où l'annonce est aussi publiée (badge « aussi sur… »)
55 + if d.get("dup_sources"):
56 + try:
57 + d["dup_sources"] = json.loads(d["dup_sources"])
58 + except (ValueError, TypeError):
59 + d["dup_sources"] = []
54 60 if d.get("furnished") is not None:
55 61 d["furnished"] = bool(d["furnished"])
56 62 return d
@@ -74,7 +80,7 @@ def list_listings(
74 80 offset: int = 0,
75 81 ):
76 82 con = db.connect()
77 − sql = "SELECT * FROM listings WHERE 1=1"
83 + sql = "SELECT * FROM listings WHERE dup_of IS NULL" # doublons masqués
78 84 args: list = []
79 85 if active in (0, 1):
80 86 sql += " AND active=?"; args.append(active)
@@ -145,7 +151,8 @@ def listings_geojson(
145 151 sql = ("SELECT uid, title, address, price, price_label, unit_type,"
146 152 " availability_date, source, city, sector, area_sqft, images,"
147 153 " lat, lng FROM listings"
148 − " WHERE active=1 AND lat IS NOT NULL AND lng IS NOT NULL")
154 + " WHERE active=1 AND dup_of IS NULL"
155 + " AND lat IS NOT NULL AND lng IS NOT NULL")
149 156 args: list = []
150 157 if bbox:
151 158 try:
@@ -279,20 +286,24 @@ def facets(city: str | None = None):
279 286 utilisé par le sélecteur « Quartier » dépendant de « Ville ».
280 287 """
281 288 con = db.connect()
282 − sector_sql = "SELECT DISTINCT sector FROM listings WHERE active=1 AND sector<>''"
289 + sector_sql = ("SELECT DISTINCT sector FROM listings"
290 + " WHERE active=1 AND dup_of IS NULL AND sector<>''")
283 291 sector_args: list = []
284 292 if city:
285 293 sector_sql += " AND city=?"
286 294 sector_args.append(city)
287 295 out = {
288 296 "cities": [r["city"] for r in con.execute(
289 − "SELECT DISTINCT city FROM listings WHERE active=1 AND city<>'' ORDER BY city")],
297 + "SELECT DISTINCT city FROM listings WHERE active=1 AND dup_of IS NULL"
298 + " AND city<>'' ORDER BY city")],
290 299 "sectors": [r["sector"] for r in con.execute(
291 300 sector_sql + " ORDER BY sector", sector_args)],
292 301 "unit_types": [r["unit_type"] for r in con.execute(
293 − "SELECT DISTINCT unit_type FROM listings WHERE active=1 AND unit_type<>'' ORDER BY unit_type")],
302 + "SELECT DISTINCT unit_type FROM listings WHERE active=1"
303 + " AND dup_of IS NULL AND unit_type<>'' ORDER BY unit_type")],
294 304 "sources": [dict(r) for r in con.execute(
295 − "SELECT source, COUNT(*) n FROM listings WHERE active=1 GROUP BY source ORDER BY n DESC")],
305 + "SELECT source, COUNT(*) n FROM listings WHERE active=1"
306 + " AND dup_of IS NULL GROUP BY source ORDER BY n DESC")],
296 307 }
297 308 con.close()
298 309 return out
@@ -340,7 +351,7 @@ def stats():
340 351 THEN 1 ELSE 0 END) autres,
341 352 COUNT(DISTINCT source) sources,
342 353 AVG(price) avg_price
343 − FROM listings WHERE active=1""",
354 + FROM listings WHERE active=1 AND dup_of IS NULL""",
344 355 list(_VILLES_GM) + list(_VILLES_GM)).fetchone()
345 356 log = [dict(r) for r in con.execute(
346 357 "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")]
347 358