# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/devloc.py : connecteur Devloc (devloc.ca / app.devloc.ca) # L'inventaire vit dans l'ERP Laravel/Vue app.devloc.ca : l'API publique # paginée GET /properties-public?page=N (Accept: application/json) retourne # tout : type (TYPE_STUDIO/TYPE_3_5…), pi², loyer demandé, date de # disponibilité, adresse géocodée (lat/lng), quartier (region.name), photos # (uploads.devloc.ca), inclusions actives, tolérance chiens/chats et # promotion. Seules les unités is_available (province QC) sont retenues. # Une annonce par unité ; external_id = id numérique de l'ERP ; # URL publique : https://app.devloc.ca/public/. # ----------------------------------------------------------------------------- from __future__ import annotations import re from ..schema import Listing from .base import BaseConnector APP = "https://app.devloc.ca" API_URL = f"{APP}/properties-public" TYPE_RE = re.compile(r"TYPE_(\d)_5") _HEADERS = {"Accept": "application/json", "X-Requested-With": "XMLHttpRequest"} _MAX_PAGES = 30 # garde-fou pagination def _unit_type(t: str) -> str: if t == "TYPE_STUDIO": return "Studio" m = TYPE_RE.fullmatch(t or "") return f"{m.group(1)}½" if m else "" def _pets(dog: bool | None, cat: bool | None) -> str | None: if dog is None and cat is None: return None if dog and cat: return "oui" if not dog and not cat: return "non" return "conditions" class DevlocConnector(BaseConnector): source_id = "devloc" request_delay = 0.7 def fetch(self) -> list[Listing]: listings: list[Listing] = [] page, last_page = 1, 1 while page <= min(last_page, _MAX_PAGES): data = self.get(API_URL, params={"page": page}, headers=_HEADERS).json() last_page = int((data.get("meta") or {}).get("last_page") or 1) for p in data.get("data") or []: try: lst = self._listing(p) if lst: listings.append(lst) except Exception: continue page += 1 return listings def _listing(self, p: dict) -> Listing | None: if not p.get("is_available"): return None adr = p.get("address") or {} if (adr.get("province") or "").strip().lower() != "qc": return None pid = str(p.get("id")) slug = p.get("slug") or pid street = (adr.get("address_1") or "").strip() unite = (adr.get("address_2") or "").strip() city = (adr.get("city") or "").strip() or "Montréal" postal = (adr.get("postal_code") or "").strip().upper() address = street + (f", app. {unite}" if unite else "") if city: address += f", {city}" if postal: address += f", QC {postal}" try: lat = float(adr["latitude"]) if adr.get("latitude") else None lng = float(adr["longitude"]) if adr.get("longitude") else None except (TypeError, ValueError): lat = lng = None region = ((p.get("region") or {}).get("name") or "").strip() sector = "" if region.startswith("*") else region ut = _unit_type(p.get("type") or "") price = p.get("property_status_asked_rent") try: price = float(price) if price else None except (TypeError, ValueError): price = None area = None try: area = float(p["square_feet_area"]) \ if p.get("square_feet_area") else None except (TypeError, ValueError): pass date = (p.get("availability_date") or p.get("property_status_starting_date") or "")[:10] availability = f"Disponible le {date}" if date else "Disponible" amenities = [str(i.get("inclusion_label") or "").strip().capitalize() for i in (p.get("active_inclusions") or []) if i.get("inclusion_label")] if p.get("has_laundry_room"): amenities.append("Salle de lavage") if p.get("number_of_balconies"): amenities.append(f"{p['number_of_balconies']} balcon(s)") desc = "" promo = (p.get("promotion") or "").strip() if promo: desc = f"Promotion : {promo}." notes = (p.get("tolerance_notes") or "").strip() if notes: desc = f"{desc} {notes}".strip() titre_type = ut or "logement" return Listing( source=self.source_id, external_id=pid, url=f"{APP}/public/{slug}", title=f"{street} — {titre_type}" + (f" · app. {unite}" if unite else ""), address=address, sector=sector, city=city, unit_type=ut, price=price, price_label=f"{price:.0f} $" if price else "", availability=availability, availability_date=date if re.fullmatch(r"\d{4}-\d{2}-\d{2}", date) else None, area_sqft=area, pets=_pets(p.get("dog_tolerance"), p.get("cat_tolerance")), description=desc, amenities=amenities, images=[f.get("url") for f in (p.get("files") or []) if f.get("url")][:15], lat=lat, lng=lng, )