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>
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 | |