spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/axia.py : connecteur AXIA Appartements (axiaappartements.com)5# Complexe locatif neuf à Lachine (Montréal), géré par Pur Immobilia.6# Site vitrine WordPress/WPBakery une page : deux configurations7# (2 chambres 4½ et 3 chambres 5½) avec superficie et prix8# « à partir de » — 1 annonce par configuration.9# -----------------------------------------------------------------------------10from __future__ import annotations1112import re1314from bs4 import BeautifulSoup1516from ..schema import Listing, parse_price17from .base import BaseConnector1819BASE = "https://www.axiaappartements.com"2021ADDRESS = "200, boul. Saint-Joseph, Lachine (Québec) H8S 2L3"2223IMG_RE = re.compile(24 r"https://www\.axiaappartements\.com/wp-content/uploads/"25 r"[^\"\s\\]+?\.(?:jpg|jpeg|webp)", re.I)262728class AxiaConnector(BaseConnector):29 source_id = "axia"30 request_delay = 0.63132 def fetch(self) -> list[Listing]:33 listings: list[Listing] = []34 try:35 html = self.get(BASE + "/").text36 except Exception:37 return listings38 soup = BeautifulSoup(html, "html.parser")3940 # Photos du complexe (icônes/logos exclus)41 images = [u for u in dict.fromkeys(IMG_RE.findall(html))42 if not re.search(r"icone|logo|favicon|fleche|-\d+x\d+\.",43 u, re.I)][:25]4445 # Description (meta + paragraphe « APPARTEMENTS » sur les unités)46 desc = ""47 og = soup.find("meta", attrs={"property": "og:description"}) or \48 soup.find("meta", attrs={"name": "description"})49 if og and og.get("content"):50 desc = og["content"].strip()51 for p in soup.find_all("p"):52 txt = re.sub(r"\s+", " ", p.get_text(" ", strip=True))53 if "électroménagers" in txt: # descriptif des unités54 desc = (desc + " " + txt).strip()55 break56 desc = desc[:600]57 amenities = []58 for el in soup.select(".icon-label"):59 txt = re.sub(r"\s+", " ", el.get_text(" ", strip=True)).strip()60 if txt and txt not in amenities:61 amenities.append(txt)6263 # Contact (liens tel:/mailto: structurés — location résidentielle)64 contact: dict = {}65 tel = soup.select_one('a[href^="tel:"]')66 if tel:67 digits = re.sub(r"\D", "", tel.get("href", ""))[-10:]68 if len(digits) == 10:69 contact["phone"] = "-".join(70 (digits[:3], digits[3:6], digits[6:]))71 mail = soup.select_one('a[href^="mailto:"]')72 if mail:73 contact["email"] = mail.get("href", "")[7:].split("?")[0]7475 # Disponibilité (texte du site) + promotion (dans la description,76 # pour ne pas fausser la date de disponibilité normalisée)77 page_text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True))78 availability = "Disponible"79 if re.search(r"Maintenant disponible à la location", page_text, re.I):80 availability = "Maintenant disponible à la location"81 m = re.search(r"(\d\s*MOIS OFFERTS[^<*]{0,80})", page_text, re.I)82 if m:83 promo = "Promotion : " + re.sub(r"\s+", " ", m.group(1)).strip()84 desc = (desc + " — " + promo)[:600]8586 # Cartes de prix : « 2 chambres (4 ½) », superficie, « À partir de … $ »87 for card in soup.select(".price-card"):88 try:89 lst = self._parse_card(card, images, desc, amenities,90 availability, contact)91 except Exception:92 continue93 if lst:94 listings.append(lst)95 return listings9697 def _parse_card(self, card, images, desc, amenities,98 availability, contact=None) -> Listing | None:99 head = card.get_text(" ", strip=True) # « 2 chambres (4 ½) »100 m = re.search(r"(\d)\s*chambres?", head, re.I)101 mtype = re.search(r"\(\s*(\d)\s*(?:½|1/2)\s*\)", head)102 if not (m or mtype):103 return None104 if mtype:105 unit_type = f"{mtype.group(1)}½"106 else:107 n = int(m.group(1))108 unit_type = {0: "Studio", 1: "3½", 2: "4½", 3: "5½"}.get(109 n, f"{n} chambres")110111 # superficie et prix dans les blocs suivants du même conteneur112 container = card.parent113 sqft = price_label = ""114 if container:115 sq = container.select_one(".superficie")116 if sq:117 sqft = sq.get_text(" ", strip=True)118 mt = container.select_one(".montant")119 if mt:120 price_label = mt.get_text(" ", strip=True)121122 price = parse_price(price_label)123 n_ch = m.group(1) if m else {"4½": "2", "5½": "3"}.get(unit_type, "")124125 return Listing(126 source=self.source_id,127 external_id=f"axia-lachine-{unit_type.replace('½', '.5')}",128 url=BASE + "/#appartements",129 title=f"AXIA Appartements — {n_ch} chambres ({unit_type})",130 address=ADDRESS,131 sector="Lachine",132 city="Montréal",133 unit_type=unit_type,134 price=price,135 price_label=re.sub(r"\s+", " ", price_label).strip(),136 availability=availability,137 description=(f"{sqft}. {desc}" if sqft else desc)[:600],138 amenities=amenities[:15],139 details={"contact": dict(contact)} if contact else {},140 images=images,141 )142