]*>([^<]{2,400})<', re.I)
_AREA_VAL_RE = re.compile(r'([\d\s .,]+?)\s*(MC|M2|M²|PC|PI2|PI²)\s*$', re.I)
_SQFT_PER_SQM = 10.7639
class GlmcConnector(BaseConnector):
source_id = "glmc_ag_qc"
request_delay = 0.4
max_pages = 40 # garde-fou (60/page)
def fetch(self) -> list[PropertyListing]:
by_id: dict[str, PropertyListing] = {}
dry = 0
for page in range(1, self.max_pages + 1):
url = f"{LISTING}?pg={page}"
try:
# SiteGround sert un challenge sgcaptcha en HTTP 202 (sans
# exception) : get_resilient escalade (Oxylabs → Scrapfly ASP
# → Bright Data)
html = self.get_resilient(url).text
except Exception:
break
# exclure la section « Vendus » (fiches vendues, après la pagination)
html = html.split(">Vendus<")[0]
before = len(by_id)
for blk in _CARD_SPLIT.split(html)[1:]:
blk = blk[:2500]
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="v2",
fetch_html=lambda u: self.get_resilient(u).text)
return listings
def _card(self, blk: str) -> PropertyListing | None:
hm = _HREF_RE.match(blk) or _HREF_RE.search(blk[:400])
if not hm:
return None
url, _slug, mls = hm.group(1), hm.group(2), hm.group(3)
title = ""
tm = _TITLE_RE.search(blk)
if tm:
title = _html.unescape(tm.group(1)).strip()
# dernière virgule = ville (« 37, Rue Lionel-Chalifour, Rivière-du-Loup »)
city = ""
if title.count(",") >= 1:
city = title.rsplit(",", 1)[1].strip()
im = _IMG_RE.search(blk)
pm = _PRICE_RE.search(blk)
price_label = _html.unescape(pm.group(1)).replace(" ", " ").strip() if pm else ""
return PropertyListing(
source=self.source_id,
external_id=mls,
url=url,
title=title or "Propriété à vendre",
address=title,
city=city,
price=parse_price(price_label),
price_label=price_label,
mls=mls,
images=[im.group(1)] if im else [],
agency=AGENCY,
broker_name=AGENCY,
)
def _txt(fragment: str) -> str:
"""HTML -> texte plat propre (une ligne)."""
t = _html.unescape(re.sub(r"<[^>]+>", " ", fragment))
return re.sub(r"\s+", " ", t.replace("\xa0", " ")).strip()
def _to_sqft(val: str) -> float | None:
"""« 1729.60 MC » / « 2 400 PC » -> pieds carrés."""
m = _AREA_VAL_RE.search(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).upper() in ("MC", "M2", "M²"):
n *= _SQFT_PER_SQM
return round(n, 1) or None
def _parse_detail(html: str) -> dict:
"""Fiche détail GLMC (thème X) : stats à icônes, remarques, grilles x-cell,
inclusions/exclusions, courtier(s), galerie pleine résolution."""
out: dict = {}
# description = remarques Centris (
)
dm = _DESC_RE.search(html)
if dm:
paras = [_txt(li) for li in _LI_RE.findall(dm.group(1))]
desc = "\n\n".join(p for p in paras if p)
if desc:
out["description"] = desc
# stats à icônes (pièces / chambres / sdb / salles d'eau)
details: dict = {}
sm = _STAT_ROOMS_RE.search(html)
if sm:
details["Nombre de pièces"] = sm.group(1)
for rx, field in ((_STAT_BEDS_RE, "bedrooms"),
(_STAT_BATHS_RE, "bathrooms"),
(_STAT_POWDER_RE, "powder_rooms")):
m = rx.search(html)
if m and int(m.group(1)) > 0:
out[field] = int(m.group(1))
# grilles x-cell : cellules alternées libellé / valeur (avant les courtiers)
seg = html.split("Courtier(s)")[0]
cells = [_txt(c) for c in _CELL_RE.findall(seg)]
for i in range(0, len(cells) - 1, 2):
lab, val = cells[i], cells[i + 1]
if not lab or not val or len(lab) > 45 or len(val) > 120 or lab == val:
continue
if lab in ("Terrain", "Bâtiment"): # sections Dimensions vs Superficie
if re.search(r"\d\s*[xX]\s*\d", val):
lab = f"Dimensions du {lab.lower()}"
else:
lab = f"Superficie du {lab.lower()}"
elif lab == "Habitable":
lab = "Superficie habitable"
details[lab] = val
if details.get("Superficie du terrain"):
sq = _to_sqft(details["Superficie du terrain"])
if sq:
out["lot_sqft"] = sq
if details.get("Superficie habitable"):
sq = _to_sqft(details["Superficie habitable"])
if sq:
out["area_sqft"] = sq
my = re.search(r"\b(1[6-9]\d{2}|20\d{2})\b", details.get("Année de construction", ""))
if my:
out["year_built"] = int(my.group(1))
# inclusions / exclusions (x-text hors grille)
for rx, lab in ((_INCL_RE, "Inclusions"), (_EXCL_RE, "Exclusions")):
m = rx.search(seg)
if m:
v = _txt(m.group(1))
if v:
details[lab] = v
if details:
out["details"] = details
# courtier inscripteur (première carte .mw-courtier-card)
bm = _BROKER_RE.search(html)
if bm:
out["broker_name"] = _txt(bm.group(1))
tm = _BROKER_TEL_RE.search(html)
if tm:
t = tm.group(1)
if len(t) == 10:
t = f"{t[:3]} {t[3:6]}-{t[6:]}"
out["broker_phone"] = t
# prix « 349 900 $ »
mp = re.search(r'([\d][\d\s ]{2,}\$)', html)
if mp:
p = parse_price(mp.group(1))
if p:
out["price"] = p
out["price_label"] = mp.group(1).replace(" ", " ").strip()
co = du.gmaps_coords(html)
if co:
out["lat"], out["lng"] = co
# galerie : photos Centris re-hébergées (nom de fichier = 32 hex) —
# pleine résolution, ordre d'origine ; exclut logos/portraits (noms lisibles)
seen, uniq = set(), []
for u in _GALLERY_RE.findall(html):
if u not in seen:
seen.add(u)
uniq.append(u)
if uniq:
out["images"] = uniq
return out