| 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 |
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 |
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 |
|