SPB Git forge

spb/immo-ka

Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%

[ka2] fix connecteur kw_distinction : cert TLS expiré + recherche en 500 → verify=False + repli pages courtiers

Double panne côté kwdistinction.com le 2026-08-23 : (1) cert Let's Encrypt
expiré à 12:02 UTC → SSLError sur toute requête directe (l'escalade payante
finissait sur un 200 vide de Bright Data, d'où found=0 « propre ») ;
(2) la recherche globale /Pages/Listings/Default.aspx répond 500 après 30 s
(redirect /Pages/500/) alors que fiches détail et pages courtier fonctionnent.

Correctifs : session en verify=False (commenté/daté), et si la recherche
paginée donne 0 carte, repli sur l'annuaire des courtiers (25 pages courtier,
même markup listing_bloc, pagination PageID par courtier) ; fin de pagination
détectée sur les ids vus dans le parcours courant pour ne pas être stoppée
par les co-inscriptions d'un confrère. Volume mesuré : 0 → 197 (médiane 200),
146 avec prix (~144 historique), 100 % photos/adresses.
Simon-Pierre Boucher committed 1 mo ago (Aug 24, 2026) parent 9e1db67

2 changed files +67 −12

modified data/sources.json +1 −1
@@ -85,7 +85,7 @@
85 85 "connector": "kw_distinction",
86 86 "status": "actif",
87 87 "type": "franchise",
88 − "note": "ASP.NET (Olivex/tonikweb) : page resultats rendue serveur, paginee PageID=1..N (~6 cartes/page) — cartes completes (type, prix, adresse, ville/secteur, MLS, cac/sdb, resume, photo). Fiches detail rendues serveur : galerie /_upload/photos/{id}-{n}.jpg, description, tableau Details, proximite. NE PAS utiliser sitemap.xml (URLs anciennes sector--city -> /Pages/Erreur/). ~197 inscriptions dont ~144 avec prix (reste locations/commercial au pi2)."
88 + "note": "ASP.NET (Olivex/tonikweb) : page resultats rendue serveur, paginee PageID=1..N (~6 cartes/page) — cartes completes (type, prix, adresse, ville/secteur, MLS, cac/sdb, resume, photo). Fiches detail rendues serveur : galerie /_upload/photos/{id}-{n}.jpg, description, tableau Details, proximite. NE PAS utiliser sitemap.xml (URLs anciennes sector--city -> /Pages/Erreur/). ~197 inscriptions dont ~144 avec prix (reste locations/commercial au pi2). 2026-08-23 : cert TLS expire (verify=False en session) et recherche globale /Pages/Listings/Default.aspx en 500 -> repli du connecteur sur les pages courtier (annuaire courtier-immobilier-hull, memes cartes listing_bloc, pagination PageID par courtier)."
89 89 },
90 90 {
91 91 "id": "vendirect",
modified immoka/connectors/kw_distinction.py +66 −11
@@ -11,6 +11,12 @@
11 11 # ⚠️ Ne PAS revenir au sitemap.xml : il est figé (anciennes URL
12 12 # « sector--city » qui redirigent toutes vers /Pages/Erreur/) — c'est ce qui
13 13 # avait laissé la source à 0 % prix/photos/description.
14 +# ⚠️ 2026-08-23 : double panne côté site — (1) cert TLS expiré (Let's Encrypt
15 +# non renouvelé) → SSLError sur toute requête directe ; (2) la recherche
16 +# globale (/Pages/Listings/Default.aspx) répond 500 → redirect /Pages/500/.
17 +# Parades : session en verify=False, et si la recherche donne 0 carte, repli
18 +# sur les pages courtier (même markup listing_bloc, module distinct qui
19 +# fonctionne) énumérées depuis l'annuaire des courtiers.
14 20 # -----------------------------------------------------------------------------
15 21 from __future__ import annotations
16 22
@@ -18,17 +24,25 @@ import html as _html
18 24 import os
19 25 import re
20 26
27 +import requests
28 +import urllib3
29 +
21 30 from .base import BaseConnector
22 31 from . import _detailutil as du
23 32 from ..normalize import parse_price, parse_year
24 33 from ..schema import PropertyListing
25 34
26 35 BASE = "https://www.kwdistinction.com"
27 −LIST = (BASE + "/maison-a-vendre-au-quebec/condo-a-vendre-au-quebec/"
28 − "?intMunicipaliteId=&intTypeHabitation=&curPrixFrom=0&curPrixTo=999999999"
29 − "&PageID={page}&intListeMode=1&intSecteur=&intTri=2")
36 +QS = ("?intMunicipaliteId=&intTypeHabitation=&curPrixFrom=0&curPrixTo=999999999"
37 + "&PageID={page}&intListeMode=1&intSecteur=&intTri=2")
38 +LIST = BASE + "/maison-a-vendre-au-quebec/condo-a-vendre-au-quebec/" + QS
39 +COURTIERS = (BASE + "/agence-immobiliere-au-quebec/courtier-immobilier-quebec/"
40 + "courtier-immobilier-hull")
30 41 DETAIL_LIMIT = int(os.environ.get("IMMOKA_KW_DETAIL_LIMIT", "400"))
31 42
43 +_BROKER_RE = re.compile(
44 + r'href="(/courtier-immobilier-quebec/courtier-immobilier-hull/[^"?#]+)')
45 +
32 46 _TOTAL_RE = re.compile(r"(\d+)\s*r[ée]sultat", re.I)
33 47 _HREF_RE = re.compile(r'href="(/maisons?-a-vendre-[^"]+/(\d{6,}))"')
34 48 _TITLE_RE = re.compile(r'class="listing_text_title"[^>]*>(.*?)</p>', re.S)
@@ -67,28 +81,69 @@ class KwDistinctionConnector(BaseConnector):
67 81 source_id = "kw_distinction"
68 82 request_delay = 0.5
69 83
84 + def __init__(self) -> None:
85 + super().__init__()
86 + # cert TLS du site expiré le 2026-08-23 (Let's Encrypt non renouvelé) :
87 + # sans verify=False, chaque requête directe lève SSLError et la source
88 + # tombe à 0 (l'escalade payante finissait sur un 200 vide).
89 + self.session.verify = False
90 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
91 +
70 92 def fetch(self) -> list[PropertyListing]:
71 93 by_id: dict[str, PropertyListing] = {}
94 + try:
95 + self._scan(LIST, by_id, read_total=True)
96 + except requests.RequestException:
97 + pass
98 + if not by_id:
99 + # recherche globale en panne (500 serveur, vu 2026-08-23) : repli
100 + # sur les pages courtier — même cartes listing_bloc, module qui
101 + # fonctionne ; la dédup par external_id absorbe les co-inscriptions.
102 + for path in self._broker_paths():
103 + try:
104 + self._scan(BASE + path + QS, by_id)
105 + except requests.RequestException:
106 + continue
107 + listings = list(by_id.values())
108 + du.enrich(self, listings, DETAIL_LIMIT, parse_kw_distinction_detail, key="v2")
109 + return listings
110 +
111 + def _scan(self, url_tmpl: str, by_id: dict[str, PropertyListing],
112 + read_total: bool = False) -> None:
113 + """Pagine une liste (PageID=1..N) et accumule les cartes dans by_id.
114 +
115 + La fin de pagination est détectée sur les ids vus DANS CE PARCOURS
116 + (`local`) : une page courtier pleine de co-inscriptions déjà connues
117 + d'un confrère ne doit pas stopper le parcours de ses propres pages."""
72 118 total = None
119 + local: set[str] = set()
73 120 page = 1
74 121 while page <= 80: # garde-fou (~6 cartes/page)
75 − html = self.get(LIST.format(page=page)).text
76 − if total is None:
122 + html = self.get(url_tmpl.format(page=page)).text
123 + if read_total and total is None:
77 124 m = _TOTAL_RE.search(html)
78 125 total = int(m.group(1)) if m else None
79 126 new = 0
80 127 for bloc in html.split('class="listing_bloc"')[1:]:
81 128 bloc = bloc[:5000] # ne pas déborder sur le pied de page
82 129 lst = self._to_listing(bloc)
83 − if lst is not None and lst.external_id not in by_id:
84 − by_id[lst.external_id] = lst
130 + if lst is None:
131 + continue
132 + if lst.external_id not in local:
133 + local.add(lst.external_id)
85 134 new += 1
86 − if new == 0 or (total and len(by_id) >= total):
135 + if lst.external_id not in by_id:
136 + by_id[lst.external_id] = lst
137 + if new == 0 or (total and len(local) >= total):
87 138 break
88 139 page += 1
89 − listings = list(by_id.values())
90 − du.enrich(self, listings, DETAIL_LIMIT, parse_kw_distinction_detail, key="v2")
91 − return listings
140 +
141 + def _broker_paths(self) -> list[str]:
142 + html = self.get(COURTIERS).text
143 + seen: dict[str, None] = {}
144 + for path in _BROKER_RE.findall(html):
145 + seen.setdefault(path)
146 + return list(seen)
92 147
93 148 def _to_listing(self, bloc: str) -> PropertyListing | None:
94 149 m = _HREF_RE.search(bloc)
95 150