SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
14.1 KB · 339 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Location court terme3# connectors/qldc.py : Québec Location de Chalets (quebeclocationdechalets.com)4#   — répertoire de chalets en ligne depuis 2004, contact direct avec les5#   propriétaires (pas de réservation en ligne), ~1 700 chalets.6#7# Méthode : pagination de la liste globale /chalets-a-louer?page=N (site8#   ASP.NET WebForms, 12 cartes/page, HTML statique — la pagination « infinie »9#   accepte le paramètre ?page). Cartes : id stable (/chalet-a-louer/<id>),10#   titre, région + ville, capacité, chambres, photo, et souvent un prix11#   « à partir de » (encadré .ListPrix : « Nuit 395$ » ou « Semaine 1030$ »).12#   La page détail (via self.detail, cache BD) en variante ?map=o ajoute13#   lat/lng (champs cachés InfoLocalisation_hf_lat/long — absents de la page14#   de base), grille de tarifs, description, no CITQ, sdb/lits, commodités15#   et photos.16#17# Prix : ~40 % des fiches seulement ont la grille de tarifs ; les autres ont18#   soit un tarif en texte libre (ctl16_lblvchTarif_Terme, parfois avec19#   montants — attention aux dépôts), soit rien du tout (contact direct).20#   Ordre de préférence : grille détail > texte libre détail > encadré de la21#   carte liste. Beaucoup de fiches n'affichent réellement aucun prix.22# -----------------------------------------------------------------------------23from __future__ import annotations2425import hashlib26import math27import re28import time29from urllib.parse import urljoin3031from bs4 import BeautifulSoup3233from ..schema import StListing34from .base import StConnector3536BASE = "https://www.quebeclocationdechalets.com"37LISTE = BASE + "/chalets-a-louer"3839_MONTANT = re.compile(r"(\d[\d\s  ]*(?:[.,]\d{2})?)\s*\$")4041# libellés de la région affichée → forme canonique Lou-Ka (le reste passe42# tel quel à normalize_region dans finalize())43_REGIONS = {44    "Saguenay-Lac-St-Jean": "Saguenay–Lac-Saint-Jean",45    "Estrie-Cantons-de-lest": "Cantons-de-l'Est",46    "Laval-Rive-nord": "Laval",47}484950def _prix_nuit(periode: str, prix: str) -> float | None:51    """(« Week-end 2 nuits », « 995$ - 1195$ ») → 497.5 (le plus bas)."""52    montants = []53    for m in _MONTANT.finditer(prix or ""):54        try:55            montants.append(float(56                re.sub(r"[\s  ]", "", m.group(1)).replace(",", ".")))57        except ValueError:58            pass59    if not montants:60        return None61    val = min(montants)62    lab = (periode or "").lower()63    m = re.search(r"(\d+)\s*(?:nuit|jour)", lab)64    if m and int(m.group(1)) >= 1:65        return round(val / int(m.group(1)), 2)66    if "sem" in lab:67        return round(val / 7, 2)68    if "mois" in lab:69        return None70    return val717273class QuebecLocationDeChalets(StConnector):74    source_id = "qldc"7576    def fetch(self) -> list[StListing]:77        listings: list[StListing] = []78        vus: set[str] = set()79        page, max_page = 1, 180        while page <= max_page:81            html = self.get(LISTE, params={"page": page}).text82            soup = BeautifulSoup(html, "html.parser")83            if page == 1:      # « 1722 chalets à louer » → nombre de pages84                m = re.search(r"(\d+)\s+chalets à louer", html)85                if m:86                    max_page = math.ceil(int(m.group(1)) / 12) + 287            nouveaux = 088            for a in soup.select('a[href^="/chalet-a-louer/"]'):89                lst = self._carte(a)90                if lst is not None and lst.external_id not in vus:91                    vus.add(lst.external_id)92                    listings.append(lst)93                    nouveaux += 194            if nouveaux == 0 and page > 1:95                break96            page += 19798        for lst in listings:99            # « v2 » : tarif en texte libre ajouté au parseur détail100            cle = hashlib.sha1(("|".join([101                lst.title, lst.city, lst.region,102                str(lst.capacity), str(lst.bedrooms),103            ]) + time.strftime("|%Y-%m|v2")).encode("utf-8")).hexdigest()104            try:105                d = self.detail(lst.external_id, cle,106                                lambda u=lst.url: self._detail(u))107            except Exception:108                d = {}109            if not d:110                continue111            # le prix de la page détail prime ; sinon on garde celui de la112            # carte de liste (« à partir de … »)113            if d.get("price_night") is not None:114                lst.price_night = d["price_night"]115            if d.get("price_label"):116                lst.price_label = d["price_label"]117            lst.description = d.get("description") or ""118            lst.citq = d.get("citq") or ""119            lst.amenities = d.get("amenities") or []120            lst.lat = d.get("lat")121            lst.lng = d.get("lng")122            lst.pets = d.get("pets")123            if d.get("bathrooms") is not None:124                lst.bathrooms = d["bathrooms"]125            if d.get("beds") is not None:126                lst.beds = d["beds"]127            if d.get("capacity") is not None:128                lst.capacity = d["capacity"]129            if d.get("bedrooms") is not None:130                lst.bedrooms = d["bedrooms"]131            if d.get("images"):132                lst.images = d["images"]133            lst.details.update(d.get("details") or {})134        return listings135136    # -- carte de la liste ------------------------------------------------------137    def _carte(self, lien) -> StListing | None:138        m = re.match(r"/chalet-a-louer/(\d+)$", lien.get("href", ""))139        if m is None:140            return None141        # on ancre sur le <h3> (lien-titre) pour ne traiter chaque carte qu'une142        # fois (le même href apparaît aussi sur la photo)143        h3 = lien.find_parent("h3")144        if h3 is None:145            return None146        eid = m.group(1)147        carte = h3.parent            # conteneur de la carte148        titre = lien.get_text(" ", strip=True)149150        region = ville = ""151        bloc = h3.find_next_sibling("div")152        if bloc is not None:153            morceaux = [t.strip() for t in bloc.stripped_strings if t.strip()]154            if len(morceaux) >= 2:155                region, ville = morceaux[0], morceaux[1]156            elif morceaux:157                region = morceaux[0]158159        capacite = chambres = None160        texte = carte.get_text(" ", strip=True) if carte else ""161        m2 = re.search(r"Capacité\s*(\d+)", texte)162        if m2:163            capacite = float(m2.group(1))164        m2 = re.search(r"Chambres\s*(\d+)", texte)165        if m2:166            chambres = float(m2.group(1))167168        images = []169        conteneur = carte.parent if carte is not None else None170        img = conteneur.find("img", src=re.compile("PhotoChalets")) \171            if conteneur is not None else None172        if img is not None:173            images.append(urljoin(BASE, img["src"].split("?")[0]))174175        # encadré de prix de la carte (« à partir de / Nuit 395$ » ou176        # « Semaine 1030$ ») — repli si la page détail n'affiche aucun tarif.177        # NE PAS remonter plus haut que la carte : on attraperait le prix178        # d'une carte voisine.179        prix_label, prix_nuit = "", None180        bloc = carte.select_one(".ListPrix") if carte is not None else None181        if bloc is not None:182            prix_label = re.sub(r"\s+", " ", bloc.get_text(" ", strip=True))183            prix_nuit = _prix_nuit(prix_label, prix_label)184185        return StListing(186            source=self.source_id,187            external_id=eid,188            url=f"{BASE}/chalet-a-louer/{eid}",189            title=titre,190            property_type="Chalet",191            city=ville,192            region=_REGIONS.get(region, region),193            price_night=prix_nuit,194            price_label=prix_label,195            capacity=capacite,196            bedrooms=chambres,197            images=images,198        )199200    # -- page détail (?map=o : contenu complet + géo) ----------------------------201    def _detail(self, url: str) -> dict:202        html = self.get(url, params={"map": "o"}).text203        soup = BeautifulSoup(html, "html.parser")204        d: dict = {"details": {}}205206        def _champ(id_, conv=str):207            el = soup.find(id=id_)208            if el is None:209                return None210            val = (el.get("value") or el.get_text(" ", strip=True)).strip()211            if not val:212                return None213            try:214                return conv(val.replace(",", "."))215            except ValueError:216                return None217218        lat = _champ("InfoLocalisation_hf_lat", float)219        lng = _champ("InfoLocalisation_hf_long", float)220        if lat is not None and lng is not None:221            d["lat"], d["lng"] = lat, lng222        ville = soup.find(id="InfoLocalisation_lblVille")223        if ville is not None and ville.get_text(strip=True):224            d["details"]["ville"] = ville.get_text(strip=True)225        lac = soup.find(id="InfoLocalisation_lblLacRiviere")226        if lac is not None and lac.get_text(strip=True):227            d["details"]["plan_eau"] = lac.get_text(strip=True)228229        d["capacity"] = _champ("InfoCapaciteConfort_lblintCapacite", float)230        d["bedrooms"] = _champ("InfoCapaciteConfort_lblintChambre", float)231        d["bathrooms"] = _champ("InfoCapaciteConfort_lblSalleDeBain", float)232        lits = 0233        for id_ in ("InfoCapaciteConfort_lblLitSimple",234                    "InfoCapaciteConfort_lblLitdouble",235                    "InfoCapaciteConfort_lblintLitQueen",236                    "InfoCapaciteConfort_lblintLitKing"):237            n = _champ(id_, float)238            if n:239                lits += int(n)240        if lits:241            d["beds"] = float(lits)242243        # grille de tarifs : lignes (période | prix | commentaires) —244        # prix/nuit = le plus bas de toutes les lignes245        grille = soup.find(id="ctl16_pnlGrilleTarrif")246        meilleurs = []247        if grille is not None:248            for ligne in grille.select("div.flex.flex-wrap"):249                cols = [c.get_text(" ", strip=True)250                        for c in ligne.find_all("div", recursive=False)]251                if len(cols) >= 2 and "$" in cols[1]:252                    pn = _prix_nuit(cols[0] + " " + (cols[2] if len(cols) > 2253                                                     else ""), cols[1])254                    if pn:255                        meilleurs.append(pn)256        if meilleurs:257            d["price_night"] = min(meilleurs)258        fourchette = soup.find(itemprop="priceRange")259        if fourchette is not None:260            d["price_label"] = re.sub(r"\s+", " ",261                                      fourchette.get_text(" ", strip=True))262            if "price_night" not in d:263                d["price_night"] = _prix_nuit(d["price_label"],264                                              d["price_label"])265266        # tarif en texte libre (fiches sans grille) : on ne retient que les267        # phrases avec un montant ET une période (nuit/jour/semaine), en268        # ignorant dépôts et cautions269        if "price_night" not in d:270            terme = soup.find(id="ctl16_lblvchTarif_Terme")271            if terme is not None:272                candidats = []273                # split en phrases sans casser les décimales (« 129.00$ »)274                for phrase in re.split(r"[\n;•]|\.(?!\d)",275                                       terme.get_text("\n", strip=True)):276                    if "$" not in phrase \277                            or re.search(r"(?i)d[ée]p[ôo]t|caution|rabais", phrase) \278                            or not re.search(r"(?i)nuit|jour|sem", phrase):279                        continue280                    pn = _prix_nuit(phrase, phrase)281                    if pn:282                        candidats.append((pn, phrase.strip()))283                if candidats:284                    pn, phrase = min(candidats)285                    d["price_night"] = pn286                    d.setdefault("price_label", re.sub(r"\s+", " ", phrase)[:120])287288        desc = soup.find(id="InfoDescription_pnlDescription")289        if desc is not None:290            texte = desc.get_text("\n", strip=True)291            texte = re.sub(r"^Descriptif de la location\n?", "", texte)292            d["description"] = texte[:5000]293        citq = soup.find(id="InfoDescription_lblvchNumCITQ")294        if citq is not None:295            m = re.search(r"(\d{4,8})", citq.get_text(" ", strip=True))296            if m:297                d["citq"] = m.group(1)298        restr = soup.find(id="InfoDescription_lblvchRestriction")299        if restr is not None and restr.get_text(strip=True):300            texte = restr.get_text(" ", strip=True)301            d["details"]["restrictions"] = texte[:1000]302            if re.search(r"animaux\s+(permis|accept|admis)", texte, re.I):303                d["pets"] = "oui"304            elif re.search(r"animaux\s+(non|interdit|refus)|pas d.animaux",305                           texte, re.I):306                d["pets"] = "non"307308        # équipements : libellés dont l'icône n'est pas « -red » (= absent)309        amen: list[str] = []310        exclus = ("Maximum de personnes", "Nombre Chambres", "Salles de bain",311                  "Lits simples", "Lits doubles", "Lits Queen", "Lits King")312        for img in soup.select("img[src*='tailwind-img']"):313            p = img.find_parent("div")314            p = p.find("p") if p is not None else None315            if p is None:316                continue317            libelle = p.get_text(" ", strip=True)318            if (not libelle or libelle in exclus or libelle in amen319                    or "-red" in (img.get("src") or "")):320                continue321            amen.append(libelle)322            if libelle == "Animaux" and "pets" not in d:323                d["pets"] = "oui"324        if amen:325            d["amenities"] = [a for a in amen if a not in ("Animaux", "Fumeur")]326        # animaux : icône rouge = interdit327        for img in soup.select("img[alt='Animaux'][src*='-red']"):328            d.setdefault("pets", "non")329330        images: list[str] = []331        for m in re.finditer(r"images/PhotoChalets/[\w./-]+\.(?:jpe?g|png|webp)",332                             html, re.I):333            src = urljoin(BASE + "/", m.group(0))334            if src not in images:335                images.append(src)336        if images:337            d["images"] = images[:20]338        return d339