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 | "connector": "sutton", | 2648 | "connector": "sutton", | ||
| 2649 | "status": "actif", | 2649 | "status": "actif", | ||
| 2650 | "region": "Province" | 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 | con.close() | 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 | def apply_detail(lst: Listing, d: dict) -> None: | 191 | def apply_detail(lst: Listing, d: dict) -> None: | ||
| 132 | """Applique un payload détail au Listing sans écraser les valeurs déjà | 192 | """Applique un payload détail au Listing sans écraser les valeurs déjà | ||
| 133 | présentes (sauf images : on garde la plus grande galerie).""" | 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
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added
louka/dedup.py
+273 −0
Large diff (273 lines) is not loaded by default to keep the page fast.Load diff
modified
louka/ingest.py
+6 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
modified
louka/web.py
+18 −7
Large diff (66 lines) is not loaded by default to keep the page fast.Load diff