SPB Git

spb/lou-ka

Public

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

feat(scraping): migration Firecrawl -> Scrapfly des 6 connecteurs a rendu JS

- base.get_rendered : rendu via Scrapfly (render_js+ASP), wait_ms = ancien
  waitFor Firecrawl, retry x3 sur contenu vide puis echec honnete ;
  FIRECRAWL_API retire du socle
- aalto/osgoode/brigil/realstar : _rendered delegue au socle (retry centralise)
- realstar/osgoode/brigil : selecteur li[class*=property-box] — le rendu
  Scrapfly capture le DOM avant que le JS RentCafe ne revele les cartes
  (property-box-hidden), qui sont completes ; filtre QC en aval inchange
- via_capitale : CARD_SPLIT accepte les hrefs relatifs (serialisation
  Scrapfly) en plus des absolus (fixtures Firecrawl historiques)
- fixtures record/replay : get_rendered accepte wait_ms (cle de rejeu = URL)
- fixtures re-enregistrees via Scrapfly : aalto 24, osgoode 4, brigil 12,
  realstar 9 ; rejeu test_connectors 10/10
- gen_connector_docs : backend get_rendered documente Scrapfly (ex-Firecrawl)

Valide en live : aalto 24, lynk_olymbec 4, via_capitale page 1 = 34 cartes.
Lou-Ka ne depend plus de FIRECRAWL_API_KEY.
Simon-Pierre Boucher committed (Aug 27, 2026) parent 54f25fd Browse files

20 changed files +19,401 −214

modified louka/connectors/aalto.py +6 −14
@@ -6,7 +6,7 @@
6 # résidentiels du quartier Zibi (rive québécoise, secteur Hull de 6 # résidentiels du quartier Zibi (rive québécoise, secteur Hull de
7 # Gatineau — 10, rue Jos-Montferrand, J8X 0A6, adresse publiée par le 7 # Gatineau — 10, rue Jos-Montferrand, J8X 0A6, adresse publiée par le
8 # site). Site RentCafe/Yardi (gabarit « ritz ») derrière Cloudflare (403 8 # site). Site RentCafe/Yardi (gabarit « ritz ») derrière Cloudflare (403
9−# en direct) : rendu via Firecrawl comme realstar.py/osgoode.py. 9+# en direct) : rendu JS via Scrapfly comme realstar.py/osgoode.py (ex-Firecrawl).
10 # La page /floorplans (française) publie une carte par PLAN : nom 10 # La page /floorplans (française) publie une carte par PLAN : nom
11 # (« Aalto II | S2 »), typologie (« studio / 1 SdB »), superficie en pc, 11 # (« Aalto II | S2 »), typologie (« studio / 1 SdB »), superficie en pc,
12 # prix « à partir de $1,520.00/mois » et image du plan -> une annonce par 12 # prix « à partir de $1,520.00/mois » et image du plan -> une annonce par
@@ -16,13 +16,12 @@
16 # ----------------------------------------------------------------------------- 16 # -----------------------------------------------------------------------------
17 from __future__ import annotations 17 from __future__ import annotations
18 18
19−import os
20 import re 19 import re
21 20
22 from bs4 import BeautifulSoup 21 from bs4 import BeautifulSoup
23 22
24 from ..schema import Listing, normalize_unit_type, strip_accents 23 from ..schema import Listing, normalize_unit_type, strip_accents
25−from .base import FIRECRAWL_API, BaseConnector 24+from .base import BaseConnector
26 25
27 BASE = "https://www.aaltosuites.ca" 26 BASE = "https://www.aaltosuites.ca"
28 FLOORPLANS_URL = f"{BASE}/floorplans" 27 FLOORPLANS_URL = f"{BASE}/floorplans"
@@ -41,18 +40,11 @@ class AaltoConnector(BaseConnector):
41 request_delay = 2.0 40 request_delay = 2.0
42 max_plans = 60 41 max_plans = 60
43 42
44− # -- Firecrawl avec attente de rendu (Cloudflare + SPA RentCafe) ----------- 43+ # -- rendu JS avec attente (Cloudflare + SPA RentCafe) — Scrapfly ----------
44+ # (migré de Firecrawl le 2026-08-27 ; retry + échec honnête dans
45+ # BaseConnector.get_rendered, que les fixtures monkeypatchent)
45 def _rendered(self, url: str, wait_ms: int = 9000) -> str: 46 def _rendered(self, url: str, wait_ms: int = 9000) -> str:
46− key = os.environ.get("FIRECRAWL_API_KEY", "") 47+ return self.get_rendered(url, wait_ms)
47− # via self.session : l'enregistreur de fixtures capture la réponse
48− resp = self.session.post(
49− FIRECRAWL_API,
50− json={"url": url, "formats": ["html"], "waitFor": wait_ms},
51− headers={"Authorization": f"Bearer {key}"},
52− timeout=150,
53− )
54− resp.raise_for_status()
55− return (resp.json().get("data") or {}).get("html", "")
56 48
57 @staticmethod 49 @staticmethod
58 def _unit_type(label: str) -> str: 50 def _unit_type(label: str) -> str:
modified louka/connectors/base.py +18 −19
@@ -2,8 +2,8 @@
2 # Lou-Ka — Agrégateur de logements à louer (province de Québec) 2 # Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 # Auteur : Simon-Pierre Boucher — contact@spboucher.ai 3 # Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 # connectors/base.py : classe de base des connecteurs + backends de fetch 4 # connectors/base.py : classe de base des connecteurs + backends de fetch
5−# (requests direct, Firecrawl ou Scrapfly pour les sites 5+# (requests direct, ou Scrapfly pour les sites JavaScript
6−# JavaScript / derrière anti-bot) 6+# / derrière anti-bot — Firecrawl retiré le 2026-08-27)
7 # ----------------------------------------------------------------------------- 7 # -----------------------------------------------------------------------------
8 from __future__ import annotations 8 from __future__ import annotations
9 9
@@ -19,7 +19,6 @@ USER_AGENT = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
19 "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36 " 19 "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36 "
20 "LouKaBot/1.0 (+https://www.lou-ka.com/bot; contact@spboucher.ai)") 20 "LouKaBot/1.0 (+https://www.lou-ka.com/bot; contact@spboucher.ai)")
21 21
22−FIRECRAWL_API = "https://api.firecrawl.dev/v1/scrape"
23 SCRAPFLY_API = "https://api.scrapfly.io/scrape" 22 SCRAPFLY_API = "https://api.scrapfly.io/scrape"
24 23
25 24
@@ -65,24 +64,24 @@ class BaseConnector:
65 resp.raise_for_status() 64 resp.raise_for_status()
66 return resp 65 return resp
67 66
68− def get_rendered(self, url: str) -> str: 67+ def get_rendered(self, url: str, wait_ms: int = 0) -> str:
69− """Récupère le HTML rendu (JavaScript exécuté) via Firecrawl. 68+ """Récupère le HTML rendu (JavaScript exécuté) via Scrapfly.
70 69
71− Nécessite FIRECRAWL_API_KEY dans l'environnement (.env). 70+ Migré de Firecrawl le 2026-08-27 (même contrat : `wait_ms` équivaut à
72− À utiliser pour les sites SPA (Logisco, Locago, etc.). 71+ l'ancien `waitFor` Firecrawl). Les fixtures (record/replay) remplacent
72+ cette méthode par son nom — la clé de rejeu reste l'URL cible.
73+ Retry court sur contenu vide (l'équivalent des 502 transitoires
74+ Firecrawl vus 2026-08-21/27), puis échec HONNÊTE : mieux vaut faire
75+ échouer le sync que dépublier l'inventaire sur une page vide.
73 """ 76 """
74− key = os.environ.get("FIRECRAWL_API_KEY") 77+ for attempt in range(3):
75− if not key: 78+ html = self.get_scrapfly(url, render_js=True, asp=True,
76− raise RuntimeError("FIRECRAWL_API_KEY manquant (voir .env)") 79+ rendering_wait=wait_ms)
77− resp = requests.post( 80+ if html:
78− FIRECRAWL_API, 81+ return html
79− json={"url": url, "formats": ["html"]}, 82+ if attempt < 2:
80− headers={"Authorization": f"Bearer {key}"}, 83+ time.sleep(5 * (attempt + 1))
81− timeout=90, 84+ raise RuntimeError(f"Scrapfly : rendu vide après 3 tentatives — {url}")
82− )
83− resp.raise_for_status()
84− data = resp.json()
85− return (data.get("data") or {}).get("html", "")
86 85
87 def scrapfly(self, url: str, render_js: bool = True, asp: bool = True, 86 def scrapfly(self, url: str, render_js: bool = True, asp: bool = True,
88 rendering_wait: int = 0, country: str = "ca", 87 rendering_wait: int = 0, country: str = "ca",
modified louka/connectors/brigil.py +16 −21
@@ -4,7 +4,7 @@
4 # connectors/brigil.py : connecteur Brigil (brigil.com — Gatineau/Outaouais) 4 # connectors/brigil.py : connecteur Brigil (brigil.com — Gatineau/Outaouais)
5 # Promoteur-gestionnaire dominant de Gatineau. Site = portail Yardi RentCafe 5 # Promoteur-gestionnaire dominant de Gatineau. Site = portail Yardi RentCafe
6 # protégé par Cloudflare : curl/requests reçoivent 403 (« Just a moment »), 6 # protégé par Cloudflare : curl/requests reçoivent 403 (« Just a moment »),
7−# TOUT passe par Firecrawl avec attente de rendu — même patron que 7+# TOUT passe par le rendu Scrapfly avec attente (ex-Firecrawl) — même patron que
8 # realstar.py (RentCafe + Cloudflare) : 8 # realstar.py (RentCafe + Cloudflare) :
9 # 1) /searchlisting?province=Quebec -> cartes propriétés (nom, gamme 9 # 1) /searchlisting?province=Quebec -> cartes propriétés (nom, gamme
10 # Aura/Apogee/Comfort, lits, fourchette de prix, vignette) ; le portail 10 # Aura/Apogee/Comfort, lits, fourchette de prix, vignette) ; le portail
@@ -17,19 +17,18 @@
17 # Les propriétés d'Ottawa/Ontario sont écartées : d'abord par l'URL 17 # Les propriétés d'Ottawa/Ontario sont écartées : d'abord par l'URL
18 # (/apartments/on/…), ensuite par la ville lue sur la fiche. 18 # (/apartments/on/…), ensuite par la ville lue sur la fiche.
19 # Une annonce par propriété. Les rendus passent par self.detail(...) (cache 19 # Une annonce par propriété. Les rendus passent par self.detail(...) (cache
20−# BD) : Firecrawl n'est rappelé que si la carte a changé, et un plafond 20+# BD) : le rendu n'est rappelé que si la carte a changé, et un plafond
21 # (max_renders) borne le coût de chaque synchronisation. 21 # (max_renders) borne le coût de chaque synchronisation.
22 # ----------------------------------------------------------------------------- 22 # -----------------------------------------------------------------------------
23 from __future__ import annotations 23 from __future__ import annotations
24 24
25 import hashlib 25 import hashlib
26−import os
27 import re 26 import re
28 27
29 from bs4 import BeautifulSoup 28 from bs4 import BeautifulSoup
30 29
31 from ..schema import Listing, normalize_unit_type, parse_price, strip_accents 30 from ..schema import Listing, normalize_unit_type, parse_price, strip_accents
32−from .base import FIRECRAWL_API, BaseConnector 31+from .base import BaseConnector
33 32
34 SEARCH_URL = "https://www.brigil.com/searchlisting?province=Quebec" 33 SEARCH_URL = "https://www.brigil.com/searchlisting?province=Quebec"
35 34
@@ -53,27 +52,20 @@ _BED_LOW_RE = re.compile(r"(?i)^\s*(studio|\d+)")
53 52
54 53
55 class _BudgetReached(Exception): 54 class _BudgetReached(Exception):
56− """Plafond de rendus Firecrawl atteint pour cette synchronisation.""" 55+ """Plafond de rendus atteint pour cette synchronisation."""
57 56
58 57
59 class BrigilConnector(BaseConnector): 58 class BrigilConnector(BaseConnector):
60 source_id = "brigil" 59 source_id = "brigil"
61 request_delay = 1.0 60 request_delay = 1.0
62 max_images = 20 61 max_images = 20
63− max_renders = 26 # plafond d'appels Firecrawl par sync (hors cache) 62+ max_renders = 26 # plafond de rendus par sync (hors cache)
64 63
65− # -- Firecrawl avec attente de rendu (RentCafe + Cloudflare) --------------- 64+ # -- rendu JS avec attente (RentCafe + Cloudflare) — Scrapfly --------------
65+ # (migré de Firecrawl le 2026-08-27 ; retry + échec honnête dans
66+ # BaseConnector.get_rendered, que les fixtures monkeypatchent)
66 def _rendered(self, url: str, wait_ms: int = 9000) -> str: 67 def _rendered(self, url: str, wait_ms: int = 9000) -> str:
67− key = os.environ.get("FIRECRAWL_API_KEY", "") 68+ return self.get_rendered(url, wait_ms)
68− # via self.session : l'enregistreur de fixtures capture la réponse
69− resp = self.session.post(
70− FIRECRAWL_API,
71− json={"url": url, "formats": ["html"], "waitFor": wait_ms},
72− headers={"Authorization": f"Bearer {key}"},
73− timeout=150,
74− )
75− resp.raise_for_status()
76− return (resp.json().get("data") or {}).get("html", "")
77 69
78 # -- helpers --------------------------------------------------------------- 70 # -- helpers ---------------------------------------------------------------
79 @staticmethod 71 @staticmethod
@@ -127,11 +119,14 @@ class BrigilConnector(BaseConnector):
127 self._renders = 0 119 self._renders = 0
128 html = self._rendered(SEARCH_URL, 10000) 120 html = self._rendered(SEARCH_URL, 10000)
129 soup = BeautifulSoup(html, "html.parser") 121 soup = BeautifulSoup(html, "html.parser")
130− cards = soup.select("li.property-box") 122+ # [class*=…] : le rendu Scrapfly capture le DOM avant que le JS
123+ # RentCafe ne révèle les cartes (property-box-hidden) — les cartes
124+ # cachées sont complètes, le filtre QC en aval fait le tri
125+ cards = soup.select('li[class*="property-box"]')
131 if not cards: # rendu incomplet : seconde chance 126 if not cards: # rendu incomplet : seconde chance
132 html = self._rendered(SEARCH_URL, 15000) 127 html = self._rendered(SEARCH_URL, 15000)
133 soup = BeautifulSoup(html, "html.parser") 128 soup = BeautifulSoup(html, "html.parser")
134− cards = soup.select("li.property-box") 129+ cards = soup.select('li[class*="property-box"]')
135 130
136 # candidats : tout sauf les fiches explicitement ontariennes 131 # candidats : tout sauf les fiches explicitement ontariennes
137 candidates: list[tuple[str, str, object]] = [] 132 candidates: list[tuple[str, str, object]] = []
@@ -201,7 +196,7 @@ class BrigilConnector(BaseConnector):
201 if img and img.get("src"): 196 if img and img.get("src"):
202 images.append(img["src"]) 197 images.append(img["src"])
203 198
204− # fiche propriété (+ plans) via cache BD : Firecrawl n'est rappelé que 199+ # fiche propriété (+ plans) via cache BD : le rendu n'est rappelé que
205 # si la carte a changé (nom, lits, prix) 200 # si la carte a changé (nom, lits, prix)
206 key = hashlib.sha1( 201 key = hashlib.sha1(
207 f"{name}|{unit_types}|{beds}|{price_label}".encode("utf-8") 202 f"{name}|{unit_types}|{beds}|{price_label}".encode("utf-8")
@@ -273,7 +268,7 @@ class BrigilConnector(BaseConnector):
273 268
274 # -- fiche propriété + plans (hors cache seulement) ------------------------ 269 # -- fiche propriété + plans (hors cache seulement) ------------------------
275 def _fetch_detail(self, url: str) -> dict: 270 def _fetch_detail(self, url: str) -> dict:
276− """1 à 2 rendus Firecrawl : fiche (adresse, photos, description, 271+ """1 à 2 rendus Scrapfly : fiche (adresse, photos, description,
277 commodités, plans intégrés) puis /floorplans si nécessaire. 272 commodités, plans intégrés) puis /floorplans si nécessaire.
278 273
279 La fiche est rendue AVANT tout : sans elle, la ville de la propriété 274 La fiche est rendue AVANT tout : sans elle, la ville de la propriété
modified louka/connectors/lynk_olymbec.py +6 −5
@@ -5,7 +5,7 @@
5 # Lynk De la Savane — 303 unités au 5200, rue De la Savane, Montréal 5 # Lynk De la Savane — 303 unités au 5200, rue De la Savane, Montréal
6 # (secteur Namur / De la Savane, CDN-NDG). Site RentCafe derrière un 6 # (secteur Namur / De la Savane, CDN-NDG). Site RentCafe derrière un
7 # challenge Cloudflare (403 pour les robots) : accès direct tenté avec 7 # challenge Cloudflare (403 pour les robots) : accès direct tenté avec
8−# en-têtes réalistes, sinon repli sur Firecrawl (rendu JS). 8+# en-têtes réalistes, sinon repli sur Scrapfly (rendu JS, ex-Firecrawl).
9 # Sources combinées (1 annonce par type d'unité — Studio, 3½, 4½, 5½) : 9 # Sources combinées (1 annonce par type d'unité — Studio, 3½, 4½, 5½) :
10 # - /lynk-dls/units : prix marketing « à partir de » par type, inclusions 10 # - /lynk-dls/units : prix marketing « à partir de » par type, inclusions
11 # « dans mon unité » (icônes) et « aménagements et matériaux » (liste) ; 11 # « dans mon unité » (icônes) et « aménagements et matériaux » (liste) ;
@@ -85,8 +85,9 @@ class LynkOlymbecConnector(BaseConnector):
85 self._detail_fetches = 0 85 self._detail_fetches = 0
86 86
87 def _get_page(self, url: str) -> str: 87 def _get_page(self, url: str) -> str:
88− """Essaie l'accès direct (en-têtes navigateur), sinon Firecrawl, 88+ """Essaie l'accès direct (en-têtes navigateur), sinon le rendu
89− sinon Scrapfly (ASP) en dernier rideau.""" 89+ Scrapfly (get_rendered, avec retry), sinon un dernier appel Scrapfly
90+ ASP direct en dernier rideau."""
90 try: 91 try:
91 resp = self.get(url, headers=BROWSER_HEADERS) 92 resp = self.get(url, headers=BROWSER_HEADERS)
92 if "Just a moment" not in resp.text: 93 if "Just a moment" not in resp.text:
@@ -94,7 +95,7 @@ class LynkOlymbecConnector(BaseConnector):
94 except Exception: 95 except Exception:
95 pass 96 pass
96 try: 97 try:
97− html = self.get_rendered(url) # Cloudflare -> rendu Firecrawl 98+ html = self.get_rendered(url) # Cloudflare -> rendu Scrapfly
98 if html and "Just a moment" not in html: 99 if html and "Just a moment" not in html:
99 return html 100 return html
100 except Exception: 101 except Exception:
@@ -145,7 +146,7 @@ class LynkOlymbecConnector(BaseConnector):
145 units_html = self._get_page(UNITS_URL) 146 units_html = self._get_page(UNITS_URL)
146 if not units_html: 147 if not units_html:
147 raise RuntimeError( 148 raise RuntimeError(
148− "page /units inaccessible (direct, Firecrawl et Scrapfly)") 149+ "page /units inaccessible (direct, rendu Scrapfly et ASP)")
149 units_text = _strip_tags(units_html) 150 units_text = _strip_tags(units_html)
150 units_soup = BeautifulSoup(units_html, "html.parser") 151 units_soup = BeautifulSoup(units_html, "html.parser")
151 152
modified louka/connectors/osgoode.py +10 −17
@@ -5,7 +5,7 @@
5 # Grand gestionnaire Ottawa-Gatineau ; côté Québec : 4 immeubles à Gatineau 5 # Grand gestionnaire Ottawa-Gatineau ; côté Québec : 4 immeubles à Gatineau
6 # (Le 700 St Joseph, Le Faubourg de l'Île, Village Cité-des-Jeunes, 6 # (Le 700 St Joseph, Le Faubourg de l'Île, Village Cité-des-Jeunes,
7 # Le Salaberry). Site RentCafe/Yardi protégé par Cloudflare (403 direct) : 7 # Le Salaberry). Site RentCafe/Yardi protégé par Cloudflare (403 direct) :
8−# tout passe par Firecrawl, comme realstar.py. 8+# tout passe par le rendu Scrapfly, comme realstar.py (ex-Firecrawl).
9 # 1) pages recherche /1-bedroom|2-bedroom/qc/gatineau/apartments -> cartes 9 # 1) pages recherche /1-bedroom|2-bedroom/qc/gatineau/apartments -> cartes
10 # propriétés (li.property-box : nom, adresse, lits/sdb/pi², fourchette 10 # propriétés (li.property-box : nom, adresse, lits/sdb/pi², fourchette
11 # de prix, téléphone, vignette) — les cartes hors Québec (liens /on/) 11 # de prix, téléphone, vignette) — les cartes hors Québec (liens /on/)
@@ -15,18 +15,17 @@
15 # ⚠ contrairement à Realstar, AUCUN décompte d'unités disponibles n'est 15 # ⚠ contrairement à Realstar, AUCUN décompte d'unités disponibles n'est
16 # publié -> availability reste vide (rien d'inventé). 16 # publié -> availability reste vide (rien d'inventé).
17 # Une annonce par propriété (uid stables). Les fiches passent par 17 # Une annonce par propriété (uid stables). Les fiches passent par
18−# self.detail(...) (cache BD) avec budget Firecrawl par sync. 18+# self.detail(...) (cache BD) avec budget de rendus par sync.
19 # ----------------------------------------------------------------------------- 19 # -----------------------------------------------------------------------------
20 from __future__ import annotations 20 from __future__ import annotations
21 21
22 import hashlib 22 import hashlib
23−import os
24 import re 23 import re
25 24
26 from bs4 import BeautifulSoup 25 from bs4 import BeautifulSoup
27 26
28 from ..schema import Listing, parse_price 27 from ..schema import Listing, parse_price
29−from .base import FIRECRAWL_API, BaseConnector 28+from .base import BaseConnector
30 29
31 BASE = "https://www.osgoodeproperties.com" 30 BASE = "https://www.osgoodeproperties.com"
32 # pages recherche par typologie (chaque carte affiche la fourchette complète 31 # pages recherche par typologie (chaque carte affiche la fourchette complète
@@ -39,7 +38,7 @@ _SKIP_IMG = re.compile(r"logo|icon|favicon|placeholder", re.I)
39 38
40 39
41 class _BudgetReached(Exception): 40 class _BudgetReached(Exception):
42− """Plafond de requêtes Firecrawl atteint pour cette synchronisation.""" 41+ """Plafond de rendus atteint pour cette synchronisation."""
43 42
44 43
45 class OsgoodeConnector(BaseConnector): 44 class OsgoodeConnector(BaseConnector):
@@ -49,18 +48,11 @@ class OsgoodeConnector(BaseConnector):
49 max_images = 20 48 max_images = 20
50 max_renders = 14 # 2 recherches + 2 par propriété (hors cache) 49 max_renders = 14 # 2 recherches + 2 par propriété (hors cache)
51 50
52− # -- Firecrawl avec attente de rendu (Cloudflare + SPA RentCafe) ----------- 51+ # -- rendu JS avec attente (Cloudflare + SPA RentCafe) — Scrapfly ----------
52+ # (migré de Firecrawl le 2026-08-27 ; retry + échec honnête dans
53+ # BaseConnector.get_rendered, que les fixtures monkeypatchent)
53 def _rendered(self, url: str, wait_ms: int = 9000) -> str: 54 def _rendered(self, url: str, wait_ms: int = 9000) -> str:
54− key = os.environ.get("FIRECRAWL_API_KEY", "") 55+ return self.get_rendered(url, wait_ms)
55− # via self.session : l'enregistreur de fixtures capture la réponse
56− resp = self.session.post(
57− FIRECRAWL_API,
58− json={"url": url, "formats": ["html"], "waitFor": wait_ms},
59− headers={"Authorization": f"Bearer {key}"},
60− timeout=150,
61− )
62− resp.raise_for_status()
63− return (resp.json().get("data") or {}).get("html", "")
64 56
65 # -- fetch ----------------------------------------------------------------- 57 # -- fetch -----------------------------------------------------------------
66 def fetch(self) -> list[Listing]: 58 def fetch(self) -> list[Listing]:
@@ -74,7 +66,8 @@ class OsgoodeConnector(BaseConnector):
74 except Exception: 66 except Exception:
75 continue 67 continue
76 soup = BeautifulSoup(html, "html.parser") 68 soup = BeautifulSoup(html, "html.parser")
77− for card in soup.select("li.property-box"): 69+ # [class*=…] : rendu Scrapfly = cartes encore property-box-hidden
70+ for card in soup.select('li[class*="property-box"]'):
78 try: 71 try:
79 a = card.select_one("a[href*='/apartments/qc/']") 72 a = card.select_one("a[href*='/apartments/qc/']")
80 if not a: 73 if not a:
modified louka/connectors/realstar.py +20 −32
@@ -3,7 +3,7 @@
3 # Auteur : Simon-Pierre Boucher — contact@spboucher.ai 3 # Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 # connectors/realstar.py : connecteur Realstar (realstar.ca) 4 # connectors/realstar.py : connecteur Realstar (realstar.ca)
5 # Site protégé par Cloudflare (403 pour les robots) et rendu côté client 5 # Site protégé par Cloudflare (403 pour les robots) et rendu côté client
6−# (moteur RentCafe/Yardi) : tout passe par Firecrawl avec attente de rendu. 6+# (moteur RentCafe/Yardi) : tout passe par le rendu Scrapfly avec attente (ex-Firecrawl).
7 # 1) /searchlisting?province=Quebec -> cartes propriétés (nom, adresse, 7 # 1) /searchlisting?province=Quebec -> cartes propriétés (nom, adresse,
8 # lits/sdb/pi², fourchette de prix, téléphone, vignette) ; 8 # lits/sdb/pi², fourchette de prix, téléphone, vignette) ;
9 # 2) fiche de chaque propriété couverte (Grand Montréal, Gatineau, 9 # 2) fiche de chaque propriété couverte (Grand Montréal, Gatineau,
@@ -11,7 +11,7 @@
11 # 3) fiche /floorplans -> plans structurés (type, chambres, pi², prix, 11 # 3) fiche /floorplans -> plans structurés (type, chambres, pi², prix,
12 # nombre d'unités disponibles) — disponibilité et prix réels. 12 # nombre d'unités disponibles) — disponibilité et prix réels.
13 # Une annonce par propriété (uid stables). Les pages détail passent par 13 # Une annonce par propriété (uid stables). Les pages détail passent par
14−# self.detail(...) (cache BD) : Firecrawl n'est rappelé que si la carte 14+# self.detail(...) (cache BD) : le rendu n'est rappelé que si la carte
15 # liste a changé. 15 # liste a changé.
16 # Expansion Ontario 2026-08 — gaté LOUKA_ONTARIO : quand LOUKA_ONTARIO=1, 16 # Expansion Ontario 2026-08 — gaté LOUKA_ONTARIO : quand LOUKA_ONTARIO=1,
17 # un rendu supplémentaire de ?province=Ontario alimente les cartes 17 # un rendu supplémentaire de ?province=Ontario alimente les cartes
@@ -25,12 +25,11 @@ from __future__ import annotations
25 import hashlib 25 import hashlib
26 import os 26 import os
27 import re 27 import re
28−import time
29 28
30 from bs4 import BeautifulSoup 29 from bs4 import BeautifulSoup
31 30
32 from ..schema import Listing, parse_price 31 from ..schema import Listing, parse_price
33−from .base import FIRECRAWL_API, BaseConnector 32+from .base import BaseConnector
34 33
35 # Expansion Ontario 2026-08 — gate d'environnement (défaut : QC seulement) 34 # Expansion Ontario 2026-08 — gate d'environnement (défaut : QC seulement)
36 _ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1" 35 _ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"
@@ -86,50 +85,39 @@ _PRICE_RE = re.compile(r"\$[\d,]+(?:\.\d{2})?")
86 85
87 86
88 class _BudgetReached(Exception): 87 class _BudgetReached(Exception):
89− """Plafond de requêtes Firecrawl atteint pour cette synchronisation.""" 88+ """Plafond de rendus atteint pour cette synchronisation."""
90 89
91 90
92 class RealstarConnector(BaseConnector): 91 class RealstarConnector(BaseConnector):
93 source_id = "realstar" 92 source_id = "realstar"
94 request_delay = 1.0 93 request_delay = 1.0
95− max_properties = 12 # garde-fou (2 appels Firecrawl par propriété) 94+ max_properties = 12 # garde-fou (2 rendus par propriété)
96 max_images = 25 95 max_images = 25
97− max_renders = 20 # plafond d'appels Firecrawl par sync (hors cache) 96+ max_renders = 20 # plafond de rendus par sync (hors cache)
98 # Expansion Ontario 2026-08 (gaté LOUKA_ONTARIO) : plafonds séparés pour 97 # Expansion Ontario 2026-08 (gaté LOUKA_ONTARIO) : plafonds séparés pour
99 # ne jamais entamer le budget QC 98 # ne jamais entamer le budget QC
100 max_properties_on = 12 99 max_properties_on = 12
101 max_renders_on = 24 100 max_renders_on = 24
102 101
103− # -- Firecrawl avec attente de rendu (SPA + Cloudflare) ------------------- 102+ # -- rendu JS avec attente (SPA + Cloudflare) — Scrapfly -------------------
103+ # (migré de Firecrawl le 2026-08-27 ; le retry court sur 5xx/vide, hérité
104+ # du fix realstar du 2026-08-27, vit désormais dans
105+ # BaseConnector.get_rendered, que les fixtures monkeypatchent)
104 def _rendered(self, url: str, wait_ms: int = 9000) -> str: 106 def _rendered(self, url: str, wait_ms: int = 9000) -> str:
105− # Clé absente : on tente quand même (le rejeu des fixtures intercepte 107+ return self.get_rendered(url, wait_ms)
106− # self.session ; en direct, Firecrawl répondra 401 -> erreur claire).
107− key = os.environ.get("FIRECRAWL_API_KEY", "")
108− # Firecrawl renvoie parfois un 502 transitoire (2026-08-21 et
109− # 2026-08-27) : un retry court évite de faire tomber tout le sync.
110− for attempt in range(3):
111− # via self.session : l'enregistreur de fixtures capture la réponse
112− resp = self.session.post(
113− FIRECRAWL_API,
114− json={"url": url, "formats": ["html"], "waitFor": wait_ms},
115− headers={"Authorization": f"Bearer {key}"},
116− timeout=150,
117− )
118− if resp.status_code >= 500 and attempt < 2:
119− time.sleep(5 * (attempt + 1))
120− continue
121− resp.raise_for_status()
122− return (resp.json().get("data") or {}).get("html", "")
123 108
124 def fetch(self) -> list[Listing]: 109 def fetch(self) -> list[Listing]:
125 self._renders = 0 110 self._renders = 0
126 html = self._rendered(SEARCH_URL, 10000) 111 html = self._rendered(SEARCH_URL, 10000)
127 soup = BeautifulSoup(html, "html.parser") 112 soup = BeautifulSoup(html, "html.parser")
128− cards = soup.select("li.property-box") 113+ # [class*=…] : le rendu Scrapfly capture le DOM avant que le JS
114+ # RentCafe ne révèle les cartes (property-box-hidden) — les cartes
115+ # cachées sont complètes, le filtre QC en aval fait le tri
116+ cards = soup.select('li[class*="property-box"]')
129 if not cards: # rendu incomplet : une seconde chance 117 if not cards: # rendu incomplet : une seconde chance
130 html = self._rendered(SEARCH_URL, 15000) 118 html = self._rendered(SEARCH_URL, 15000)
131 soup = BeautifulSoup(html, "html.parser") 119 soup = BeautifulSoup(html, "html.parser")
132− cards = soup.select("li.property-box") 120+ cards = soup.select('li[class*="property-box"]')
133 121
134 listings: list[Listing] = [] 122 listings: list[Listing] = []
135 seen: set[str] = set() 123 seen: set[str] = set()
@@ -173,11 +161,11 @@ class RealstarConnector(BaseConnector):
173 self.max_renders = self._renders + self.max_renders_on 161 self.max_renders = self._renders + self.max_renders_on
174 html = self._rendered(ON_SEARCH_URL, 12000) 162 html = self._rendered(ON_SEARCH_URL, 12000)
175 soup = BeautifulSoup(html, "html.parser") 163 soup = BeautifulSoup(html, "html.parser")
176− cards = soup.select("li.property-box") 164+ cards = soup.select('li[class*="property-box"]')
177 if not cards: # rendu incomplet : une seconde chance 165 if not cards: # rendu incomplet : une seconde chance
178 html = self._rendered(ON_SEARCH_URL, 15000) 166 html = self._rendered(ON_SEARCH_URL, 15000)
179 soup = BeautifulSoup(html, "html.parser") 167 soup = BeautifulSoup(html, "html.parser")
180− cards = soup.select("li.property-box") 168+ cards = soup.select('li[class*="property-box"]')
181 169
182 listings: list[Listing] = [] 170 listings: list[Listing] = []
183 count = 0 171 count = 0
@@ -277,7 +265,7 @@ class RealstarConnector(BaseConnector):
277 if img and img.get("src"): 265 if img and img.get("src"):
278 images.append(img["src"]) 266 images.append(img["src"])
279 267
280− # Pages détail (fiche + plans) via cache BD : Firecrawl seulement si la 268+ # Pages détail (fiche + plans) via cache BD : rendu seulement si la
281 # carte liste a changé (prix/dispo inclus dans le hash). 269 # carte liste a changé (prix/dispo inclus dans le hash).
282 key = hashlib.sha1( 270 key = hashlib.sha1(
283 f"{name}|{address}|{beds}|{baths}|{sqft}|{price_label}" 271 f"{name}|{address}|{beds}|{baths}|{sqft}|{price_label}"
@@ -362,7 +350,7 @@ class RealstarConnector(BaseConnector):
362 350
363 # -- pages détail (fiche propriété + plans) -------------------------------- 351 # -- pages détail (fiche propriété + plans) --------------------------------
364 def _fetch_detail(self, url: str) -> dict: 352 def _fetch_detail(self, url: str) -> dict:
365− """2 rendus Firecrawl : fiche (photos, description, points forts) et 353+ """2 rendus Scrapfly : fiche (photos, description, points forts) et
366 /floorplans (plans structurés). Appelé seulement hors cache.""" 354 /floorplans (plans structurés). Appelé seulement hors cache."""
367 if self._renders + 2 > self.max_renders: 355 if self._renders + 2 > self.max_renders:
368 raise _BudgetReached() 356 raise _BudgetReached()
modified louka/connectors/via_capitale.py +5 −2
@@ -3,7 +3,7 @@
3 # Auteur : Simon-Pierre Boucher — contact@spboucher.ai 3 # Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 # connectors/via_capitale.py : Via Capitale (viacapitalevendu.com) — LOCATIONS 4 # connectors/via_capitale.py : Via Capitale (viacapitalevendu.com) — LOCATIONS
5 # Bannière 100 % québécoise (Bridgemarq). Le site est rendu serveur (ASP.NET) 5 # Bannière 100 % québécoise (Bridgemarq). Le site est rendu serveur (ASP.NET)
6−# mais protégé par Cloudflare : on passe par Firecrawl (get_rendered) qui 6+# mais protégé par Cloudflare : on passe par le rendu Scrapfly (get_rendered, ex-Firecrawl) qui
7 # franchit le challenge — testé : Scrapfly ASP passe Cloudflare mais ne rend 7 # franchit le challenge — testé : Scrapfly ASP passe Cloudflare mais ne rend
8 # pas les cartes. La recherche accepte un blob base64 `criteresJson` ; 8 # pas les cartes. La recherche accepte un blob base64 `criteresJson` ;
9 # « AVendre »: false y bascule le même moteur en mode LOCATION (prix 9 # « AVendre »: false y bascule le même moteur en mode LOCATION (prix
@@ -61,7 +61,10 @@ CRITERES_JSON = base64.b64encode(
61 IMG_HOST = "https://images.viacapitale.info" 61 IMG_HOST = "https://images.viacapitale.info"
62 62
63 # Un bloc-carte commence à un lien vers une fiche d'inscription horodatée 63 # Un bloc-carte commence à un lien vers une fiche d'inscription horodatée
64−CARD_SPLIT = re.compile(r'<a href="(https://www\.viacapitalevendu\.com/[^"]*?-(\d{6,}))"') 64+# le rendu Scrapfly sérialise les hrefs en RELATIF (Firecrawl donnait
65+# l'absolu) : on accepte les deux formes, groupe 1 = chemin relatif
66+CARD_SPLIT = re.compile(
67+ r'<a href="(?:https://www\.viacapitalevendu\.com)?(/[^"]*?-(\d{6,}))"')
65 PRICE_RE = re.compile(r'class="price">\s*([\d ]+\$\s*/\s*mois)', re.I) 68 PRICE_RE = re.compile(r'class="price">\s*([\d ]+\$\s*/\s*mois)', re.I)
66 ADDR_RE = re.compile(r'addressListe[^>]*>\s*<a[^>]*title="([^"]+)"', re.I) 69 ADDR_RE = re.compile(r'addressListe[^>]*>\s*<a[^>]*title="([^"]+)"', re.I)
67 IMG_RE = re.compile(r'images\.viacapitale\.info/images/inscriptions/(\d+)/([^"?)\s]+)', re.I) 70 IMG_RE = re.compile(r'images\.viacapitale\.info/images/inscriptions/(\d+)/([^"?)\s]+)', re.I)
modified louka/fixtures.py +5 −4
@@ -106,11 +106,12 @@ def record(source_id: str) -> dict:
106 inst.session.mount("https://", adapter) 106 inst.session.mount("https://", adapter)
107 inst.session.mount("http://", adapter) 107 inst.session.mount("http://", adapter)
108 108
109− # get_rendered (Firecrawl) : enregistrer le HTML rendu, clé = URL cible 109+ # get_rendered (Scrapfly render_js) : enregistrer le HTML rendu, clé = URL
110+ # cible (les args supplémentaires — wait_ms — n'entrent pas dans la clé)
110 orig_rendered = inst.get_rendered 111 orig_rendered = inst.get_rendered
111 112
112− def _rec_rendered(url: str) -> str: 113+ def _rec_rendered(url: str, *args, **kw) -> str:
113− html = orig_rendered(url) 114+ html = orig_rendered(url, *args, **kw)
114 key = _req_key("RENDERED", url, None) 115 key = _req_key("RENDERED", url, None)
115 fname = key + ".html" 116 fname = key + ".html"
116 (out_dir / fname).write_text( 117 (out_dir / fname).write_text(
@@ -193,7 +194,7 @@ def replay_connector(source_id: str):
193 inst.session.mount("https://", adapter) 194 inst.session.mount("https://", adapter)
194 inst.session.mount("http://", adapter) 195 inst.session.mount("http://", adapter)
195 196
196− def _replay_rendered(url: str) -> str: 197+ def _replay_rendered(url: str, *args, **kw) -> str:
197 key = _req_key("RENDERED", url, None) 198 key = _req_key("RENDERED", url, None)
198 meta = index.get(key) 199 meta = index.get(key)
199 if meta is None: 200 if meta is None:
modified scripts/gen_connector_docs.py +4 −5
@@ -147,8 +147,9 @@ def detect_backends(text: str) -> tuple[list[str], str]:
147 detailed.append("Scrapfly (asp + render_js — contournement anti-bot)") 147 detailed.append("Scrapfly (asp + render_js — contournement anti-bot)")
148 family = "Scrapfly" 148 family = "Scrapfly"
149 if "get_rendered(" in text: 149 if "get_rendered(" in text:
150− detailed.append("Firecrawl (HTML rendu, JavaScript exécuté)") 150+ detailed.append("Scrapfly render_js (HTML rendu, JavaScript exécuté"
151− family = "Firecrawl" if family == "direct" else family 151+ " — ex-Firecrawl, migré 2026-08-27)")
152+ family = "Scrapfly" if family == "direct" else family
152 if not detailed: 153 if not detailed:
153 detailed.append("requests direct") 154 detailed.append("requests direct")
154 return detailed, family 155 return detailed, family
@@ -388,9 +389,7 @@ def render_fiche(entry: dict, reg: list[dict], stats: dict, now: str) -> str:
388 + " · ".join(entry["urls"])) 389 + " · ".join(entry["urls"]))
389 out.append("- **Authentification** : aucune — contenu public" 390 out.append("- **Authentification** : aucune — contenu public"
390 + (" (clé Scrapfly côté Lou-Ka)" if entry["backend_family"] == 391 + (" (clé Scrapfly côté Lou-Ka)" if entry["backend_family"] ==
391− "Scrapfly" else 392+ "Scrapfly" else ""))
392− " (clé Firecrawl côté Lou-Ka)" if entry["backend_family"] ==
393− "Firecrawl" else ""))
394 out.append(f"- **Pagination** : {entry['pagination']}") 393 out.append(f"- **Pagination** : {entry['pagination']}")
395 out.append(f"- **Backend anti-bot / rendu** : {' ; '.join(entry['backends'])}") 394 out.append(f"- **Backend anti-bot / rendu** : {' ; '.join(entry['backends'])}")
396 out.append(f"- **Politesse** : {entry['request_delay']} s entre requêtes, " 395 out.append(f"- **Politesse** : {entry['request_delay']} s entre requêtes, "
added tests/fixtures/aalto/0bc5771aff1ef3d625fa.html +6445 −0
Large diff (6,446 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/aalto/19876247196edaa337bf.html +1711 −0
Large diff (1,712 lines) is not loaded by default to keep the page fast.Load diff
deleted tests/fixtures/aalto/7f32c075ca02ee9f4d34.json +0 −1
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
modified tests/fixtures/aalto/expected.json +26 −82
Large diff (216 lines) is not loaded by default to keep the page fast.Load diff
deleted tests/fixtures/aalto/ff1e66d178eb462874f9.json +0 −1
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
modified tests/fixtures/aalto/index.json +10 −10
Large diff (27 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/brigil/027230656889b8e2307e.html +3486 −0
Large diff (3,487 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/brigil/04f917101932b7d1449f.html +3456 −0
Large diff (3,457 lines) is not loaded by default to keep the page fast.Load diff
deleted tests/fixtures/brigil/0a60655cc2caed19605e.json +0 −1
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/brigil/1507847e89ed4c1f34f4.html +3237 −0
Large diff (3,238 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/brigil/1bc1f1e0dd511ab7e609.html +940 −0
Large diff (940 lines) is not loaded by default to keep the page fast.Load diff

Diff truncated — file too large.

Menu

Theme