\s*
'
r'([^<]+)
\s*
(.*?)
', re.S | re.I)
_DTDD_RE = re.compile(r'
([^<]*)\s*(?:
([^<]*))?', re.S | re.I)
_TEL_RE = re.compile(r'Tel\.?\s*:\s*((?:1[\s-])?\d{3}[\s-]\d{3}-\d{4})')
_AREA_M2_RE = re.compile(r'([\d\s .,]+?)\s*(m|mètre|pied)', re.I)
_SQFT_PER_SQM = 10.7639
_SPEC_FIELDS = {"chambres": "bedrooms", "chambre": "bedrooms",
"salles de bain": "bathrooms", "salle de bain": "bathrooms",
"salle d'eau": "powder_rooms", "salles d'eau": "powder_rooms"}
class ImchaConnector(BaseConnector):
source_id = "imcha_ag_qc"
request_delay = 0.4
max_pages = 30
def fetch(self) -> list[PropertyListing]:
by_id: dict[str, PropertyListing] = {}
dry = 0
for page in range(1, self.max_pages + 1):
url = f"{SITE}/vente-de-proprietes/page-{page}"
try:
resp = self.get(url)
resp.encoding = "utf-8"
html = resp.text
except Exception:
break
before = len(by_id)
for blk in _ITEM_SPLIT.split(html)[1:]:
blk = blk[:4000]
lst = self._card(blk)
if lst:
by_id.setdefault(lst.external_id, lst)
dry = dry + 1 if len(by_id) == before else 0
if dry >= 2:
break
listings = list(by_id.values())
du.enrich(self, listings, DETAIL_LIMIT, _parse_detail, key="v1",
fetch_html=self._fetch_utf8)
return listings
def _fetch_utf8(self, url: str) -> str:
# le serveur n'annonce pas de charset (requests retomberait sur
# ISO-8859-1) alors que les fiches sont en UTF-8
resp = self.get(url)
resp.encoding = "utf-8"
return resp.text
def _card(self, blk: str) -> PropertyListing | None:
hm = _HREF_RE.search(blk)
if not hm:
return None
cm = _CENTRIS_RE.search(blk)
code = _CODE_RE.search(blk)
# n° Centris (photo) = clé de dédup ; sinon code interne (pas de dédup)
mls = cm.group(1) if cm else ""
external_id = mls or (code.group(1) if code else None)
if not external_id:
return None
tm = _TYPE_RE.search(blk)
cy = _CITY_RE.search(blk)
pm = _PRICE_RE.search(blk)
bm = _BEDS_RE.search(blk)
im = _IMG_RE.search(blk)
img = im.group(1) if im else ""
if img.startswith("/"):
img = SITE + img
price_label = _html.unescape(pm.group(1)).strip() if pm else ""
city = _html.unescape(cy.group(1)).strip() if cy else ""
return PropertyListing(
source=self.source_id,
external_id=external_id,
url=SITE + hm.group(1),
title=f"{_html.unescape(tm.group(1)).strip() if tm else 'Propriété'} à vendre — {city}".strip(" —"),
city=city,
property_type=_html.unescape(tm.group(1)).strip() if tm else "",
price=parse_price(price_label),
price_label=price_label,
bedrooms=int(bm.group(1)) if bm else None,
mls=mls,
images=[img] if img else [],
agency=AGENCY,
broker_name=AGENCY,
)
def _txt(fragment: str) -> str:
t = _html.unescape(re.sub(r"
", "\n", fragment, flags=re.I))
t = re.sub(r"<[^>]+>", " ", t).replace("\xa0", " ").replace("×", "x")
t = re.sub(r"[ \t]+", " ", t)
return re.sub(r"\n{3,}", "\n\n", t).strip()
def _parse_detail(html: str) -> dict:
"""Fiche détail imcha : description, galerie max, specs, sections dt/dd."""
out: dict = {}
dm = _DESC_RE.search(html)
if dm:
paras = [_txt(p) for p in _P_RE.findall(dm.group(1))]
desc = "\n\n".join(p for p in paras if p)
if desc:
out["description"] = desc
# galerie fancybox pleine résolution ; le préfixe du nom de fichier est le
# n° Centris de LA fiche → on ne garde que le lot majoritaire (sécurité)
anchors = _FANCY_RE.findall(html)
if anchors:
prefix = anchors[0].rsplit("/", 1)[-1].split("-", 1)[0]
seen, uniq = set(), []
for a in anchors:
if not a.rsplit("/", 1)[-1].startswith(prefix + "-"):
continue
u = SITE + a
if u not in seen:
seen.add(u)
uniq.append(u)
if uniq:
out["images"] = uniq
# specs à icônes (ordre libellé/nombre variable selon la spec)
for spec in _SPEC_RE.findall(html):
lm, cm = _SPEC_LABEL_RE.search(spec), _SPEC_COUNT_RE.search(spec)
if not lm or not cm:
continue
field = _SPEC_FIELDS.get(_txt(lm.group(1)).lower())
m = re.search(r"\d+", cm.group(1))
if field and m and int(m.group()) > 0:
out.setdefault(field, int(m.group()))
# sections .estate__info (
/)
details: dict = {}
for title, body in _INFO_RE.findall(html):
title = _txt(title)
pairs = [(_txt(dt), _txt(dd or "")) for dt, dd in _DTDD_RE.findall(body)]
if title.lower().startswith(("inclusions", "exclusions")):
items = [dt for dt, _ in pairs if dt]
if items:
details[title.split(" ")[0]] = ", ".join(items)
continue
for dt, dd in pairs:
dt = dt.rstrip(" :")
if not dt or not dd or len(dd) > 120:
continue
if dt == "Année de construction":
m = re.search(r"\b(1[6-9]\d{2}|20\d{2})\b", dd)
if m: # « 0 » = inconnue chez imcha
out["year_built"] = int(m.group(1))
details[dt] = dd
elif dt == "Type":
details["Type de propriété"] = dd
elif title.startswith("Évaluation municipale"):
details[f"Évaluation municipale ({dt.lower()})"] = dd
elif title in ("Terrain", "Bâtiment"):
if dt == "Superficie":
details[f"Superficie du {title.lower()}"] = dd
if title == "Terrain":
sq = _area_to_sqft(dd)
if sq:
out["lot_sqft"] = sq
else:
details[f"{title} — {dt.lower()}"] = dd
else:
details[dt] = dd
if details:
out["details"] = details
# téléphone de l'agence (meta description « Tel. : 1 418-435-6221 »)
tm = _TEL_RE.search(html)
if tm:
out["broker_phone"] = tm.group(1).strip()
return out
def _area_to_sqft(val: str) -> float | None:
"""« 7657.10 Mètres carrés » / « 2 400 pieds carrés » -> pi²."""
m = _AREA_M2_RE.match(val.strip())
if not m:
return None
try:
n = float(re.sub(r"[^\d.]", "", m.group(1)))
except ValueError:
return None
if m.group(2).lower().startswith(("m", "mètre")):
n *= _SQFT_PER_SQM
return round(n, 1) or None