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 6 # résidentiels du quartier Zibi (rive québécoise, secteur Hull de
7 7 # Gatineau — 10, rue Jos-Montferrand, J8X 0A6, adresse publiée par le
8 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 10 # La page /floorplans (française) publie une carte par PLAN : nom
11 11 # (« Aalto II | S2 »), typologie (« studio / 1 SdB »), superficie en pc,
12 12 # prix « à partir de $1,520.00/mois » et image du plan -> une annonce par
@@ -16,13 +16,12 @@
16 16 # -----------------------------------------------------------------------------
17 17 from __future__ import annotations
18 18
19 −import os
20 19 import re
21 20
22 21 from bs4 import BeautifulSoup
23 22
24 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 26 BASE = "https://www.aaltosuites.ca"
28 27 FLOORPLANS_URL = f"{BASE}/floorplans"
@@ -41,18 +40,11 @@ class AaltoConnector(BaseConnector):
41 40 request_delay = 2.0
42 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 46 def _rendered(self, url: str, wait_ms: int = 9000) -> str:
46 − key = os.environ.get("FIRECRAWL_API_KEY", "")
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", "")
47 + return self.get_rendered(url, wait_ms)
56 48
57 49 @staticmethod
58 50 def _unit_type(label: str) -> str:
modified louka/connectors/base.py +18 −19
@@ -2,8 +2,8 @@
2 2 # Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 3 # Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 4 # connectors/base.py : classe de base des connecteurs + backends de fetch
5 −# (requests direct, Firecrawl ou Scrapfly pour les sites
6 −# JavaScript / derrière anti-bot)
5 +# (requests direct, ou Scrapfly pour les sites JavaScript
6 +# / derrière anti-bot — Firecrawl retiré le 2026-08-27)
7 7 # -----------------------------------------------------------------------------
8 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 19 "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36 "
20 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 22 SCRAPFLY_API = "https://api.scrapfly.io/scrape"
24 23
25 24
@@ -65,24 +64,24 @@ class BaseConnector:
65 64 resp.raise_for_status()
66 65 return resp
67 66
68 − def get_rendered(self, url: str) -> str:
69 − """Récupère le HTML rendu (JavaScript exécuté) via Firecrawl.
67 + def get_rendered(self, url: str, wait_ms: int = 0) -> str:
68 + """Récupère le HTML rendu (JavaScript exécuté) via Scrapfly.
70 69
71 − Nécessite FIRECRAWL_API_KEY dans l'environnement (.env).
72 − À utiliser pour les sites SPA (Logisco, Locago, etc.).
70 + Migré de Firecrawl le 2026-08-27 (même contrat : `wait_ms` équivaut à
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")
75 − if not key:
76 − raise RuntimeError("FIRECRAWL_API_KEY manquant (voir .env)")
77 − resp = requests.post(
78 − FIRECRAWL_API,
79 − json={"url": url, "formats": ["html"]},
80 − headers={"Authorization": f"Bearer {key}"},
81 − timeout=90,
82 − )
83 − resp.raise_for_status()
84 − data = resp.json()
85 − return (data.get("data") or {}).get("html", "")
77 + for attempt in range(3):
78 + html = self.get_scrapfly(url, render_js=True, asp=True,
79 + rendering_wait=wait_ms)
80 + if html:
81 + return html
82 + if attempt < 2:
83 + time.sleep(5 * (attempt + 1))
84 + raise RuntimeError(f"Scrapfly : rendu vide après 3 tentatives — {url}")
86 85
87 86 def scrapfly(self, url: str, render_js: bool = True, asp: bool = True,
88 87 rendering_wait: int = 0, country: str = "ca",
modified louka/connectors/brigil.py +16 −21
@@ -4,7 +4,7 @@
4 4 # connectors/brigil.py : connecteur Brigil (brigil.com — Gatineau/Outaouais)
5 5 # Promoteur-gestionnaire dominant de Gatineau. Site = portail Yardi RentCafe
6 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 8 # realstar.py (RentCafe + Cloudflare) :
9 9 # 1) /searchlisting?province=Quebec -> cartes propriétés (nom, gamme
10 10 # Aura/Apogee/Comfort, lits, fourchette de prix, vignette) ; le portail
@@ -17,19 +17,18 @@
17 17 # Les propriétés d'Ottawa/Ontario sont écartées : d'abord par l'URL
18 18 # (/apartments/on/…), ensuite par la ville lue sur la fiche.
19 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 21 # (max_renders) borne le coût de chaque synchronisation.
22 22 # -----------------------------------------------------------------------------
23 23 from __future__ import annotations
24 24
25 25 import hashlib
26 −import os
27 26 import re
28 27
29 28 from bs4 import BeautifulSoup
30 29
31 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 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 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 58 class BrigilConnector(BaseConnector):
60 59 source_id = "brigil"
61 60 request_delay = 1.0
62 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 67 def _rendered(self, url: str, wait_ms: int = 9000) -> str:
67 − key = os.environ.get("FIRECRAWL_API_KEY", "")
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", "")
68 + return self.get_rendered(url, wait_ms)
77 69
78 70 # -- helpers ---------------------------------------------------------------
79 71 @staticmethod
@@ -127,11 +119,14 @@ class BrigilConnector(BaseConnector):
127 119 self._renders = 0
128 120 html = self._rendered(SEARCH_URL, 10000)
129 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 126 if not cards: # rendu incomplet : seconde chance
132 127 html = self._rendered(SEARCH_URL, 15000)
133 128 soup = BeautifulSoup(html, "html.parser")
134 − cards = soup.select("li.property-box")
129 + cards = soup.select('li[class*="property-box"]')
135 130
136 131 # candidats : tout sauf les fiches explicitement ontariennes
137 132 candidates: list[tuple[str, str, object]] = []
@@ -201,7 +196,7 @@ class BrigilConnector(BaseConnector):
201 196 if img and img.get("src"):
202 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 200 # si la carte a changé (nom, lits, prix)
206 201 key = hashlib.sha1(
207 202 f"{name}|{unit_types}|{beds}|{price_label}".encode("utf-8")
@@ -273,7 +268,7 @@ class BrigilConnector(BaseConnector):
273 268
274 269 # -- fiche propriété + plans (hors cache seulement) ------------------------
275 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 272 commodités, plans intégrés) puis /floorplans si nécessaire.
278 273
279 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 5 # Lynk De la Savane — 303 unités au 5200, rue De la Savane, Montréal
6 6 # (secteur Namur / De la Savane, CDN-NDG). Site RentCafe derrière un
7 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 9 # Sources combinées (1 annonce par type d'unité — Studio, 3½, 4½, 5½) :
10 10 # - /lynk-dls/units : prix marketing « à partir de » par type, inclusions
11 11 # « dans mon unité » (icônes) et « aménagements et matériaux » (liste) ;
@@ -85,8 +85,9 @@ class LynkOlymbecConnector(BaseConnector):
85 85 self._detail_fetches = 0
86 86
87 87 def _get_page(self, url: str) -> str:
88 − """Essaie l'accès direct (en-têtes navigateur), sinon Firecrawl,
89 − sinon Scrapfly (ASP) en dernier rideau."""
88 + """Essaie l'accès direct (en-têtes navigateur), sinon le rendu
89 + Scrapfly (get_rendered, avec retry), sinon un dernier appel Scrapfly
90 + ASP direct en dernier rideau."""
90 91 try:
91 92 resp = self.get(url, headers=BROWSER_HEADERS)
92 93 if "Just a moment" not in resp.text:
@@ -94,7 +95,7 @@ class LynkOlymbecConnector(BaseConnector):
94 95 except Exception:
95 96 pass
96 97 try:
97 − html = self.get_rendered(url) # Cloudflare -> rendu Firecrawl
98 + html = self.get_rendered(url) # Cloudflare -> rendu Scrapfly
98 99 if html and "Just a moment" not in html:
99 100 return html
100 101 except Exception:
@@ -145,7 +146,7 @@ class LynkOlymbecConnector(BaseConnector):
145 146 units_html = self._get_page(UNITS_URL)
146 147 if not units_html:
147 148 raise RuntimeError(
148 − "page /units inaccessible (direct, Firecrawl et Scrapfly)")
149 + "page /units inaccessible (direct, rendu Scrapfly et ASP)")
149 150 units_text = _strip_tags(units_html)
150 151 units_soup = BeautifulSoup(units_html, "html.parser")
151 152
modified louka/connectors/osgoode.py +10 −17
@@ -5,7 +5,7 @@
5 5 # Grand gestionnaire Ottawa-Gatineau ; côté Québec : 4 immeubles à Gatineau
6 6 # (Le 700 St Joseph, Le Faubourg de l'Île, Village Cité-des-Jeunes,
7 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 9 # 1) pages recherche /1-bedroom|2-bedroom/qc/gatineau/apartments -> cartes
10 10 # propriétés (li.property-box : nom, adresse, lits/sdb/pi², fourchette
11 11 # de prix, téléphone, vignette) — les cartes hors Québec (liens /on/)
@@ -15,18 +15,17 @@
15 15 # ⚠ contrairement à Realstar, AUCUN décompte d'unités disponibles n'est
16 16 # publié -> availability reste vide (rien d'inventé).
17 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 20 from __future__ import annotations
21 21
22 22 import hashlib
23 −import os
24 23 import re
25 24
26 25 from bs4 import BeautifulSoup
27 26
28 27 from ..schema import Listing, parse_price
29 −from .base import FIRECRAWL_API, BaseConnector
28 +from .base import BaseConnector
30 29
31 30 BASE = "https://www.osgoodeproperties.com"
32 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 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 44 class OsgoodeConnector(BaseConnector):
@@ -49,18 +48,11 @@ class OsgoodeConnector(BaseConnector):
49 48 max_images = 20
50 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 54 def _rendered(self, url: str, wait_ms: int = 9000) -> str:
54 − key = os.environ.get("FIRECRAWL_API_KEY", "")
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", "")
55 + return self.get_rendered(url, wait_ms)
64 56
65 57 # -- fetch -----------------------------------------------------------------
66 58 def fetch(self) -> list[Listing]:
@@ -74,7 +66,8 @@ class OsgoodeConnector(BaseConnector):
74 66 except Exception:
75 67 continue
76 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 71 try:
79 72 a = card.select_one("a[href*='/apartments/qc/']")
80 73 if not a:
modified louka/connectors/realstar.py +20 −32
@@ -3,7 +3,7 @@
3 3 # Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 4 # connectors/realstar.py : connecteur Realstar (realstar.ca)
5 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 7 # 1) /searchlisting?province=Quebec -> cartes propriétés (nom, adresse,
8 8 # lits/sdb/pi², fourchette de prix, téléphone, vignette) ;
9 9 # 2) fiche de chaque propriété couverte (Grand Montréal, Gatineau,
@@ -11,7 +11,7 @@
11 11 # 3) fiche /floorplans -> plans structurés (type, chambres, pi², prix,
12 12 # nombre d'unités disponibles) — disponibilité et prix réels.
13 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 15 # liste a changé.
16 16 # Expansion Ontario 2026-08 — gaté LOUKA_ONTARIO : quand LOUKA_ONTARIO=1,
17 17 # un rendu supplémentaire de ?province=Ontario alimente les cartes
@@ -25,12 +25,11 @@ from __future__ import annotations
25 25 import hashlib
26 26 import os
27 27 import re
28 −import time
29 28
30 29 from bs4 import BeautifulSoup
31 30
32 31 from ..schema import Listing, parse_price
33 −from .base import FIRECRAWL_API, BaseConnector
32 +from .base import BaseConnector
34 33
35 34 # Expansion Ontario 2026-08 — gate d'environnement (défaut : QC seulement)
36 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 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 91 class RealstarConnector(BaseConnector):
93 92 source_id = "realstar"
94 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 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 97 # Expansion Ontario 2026-08 (gaté LOUKA_ONTARIO) : plafonds séparés pour
99 98 # ne jamais entamer le budget QC
100 99 max_properties_on = 12
101 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 106 def _rendered(self, url: str, wait_ms: int = 9000) -> str:
105 − # Clé absente : on tente quand même (le rejeu des fixtures intercepte
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", "")
107 + return self.get_rendered(url, wait_ms)
123 108
124 109 def fetch(self) -> list[Listing]:
125 110 self._renders = 0
126 111 html = self._rendered(SEARCH_URL, 10000)
127 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 117 if not cards: # rendu incomplet : une seconde chance
130 118 html = self._rendered(SEARCH_URL, 15000)
131 119 soup = BeautifulSoup(html, "html.parser")
132 − cards = soup.select("li.property-box")
120 + cards = soup.select('li[class*="property-box"]')
133 121
134 122 listings: list[Listing] = []
135 123 seen: set[str] = set()
@@ -173,11 +161,11 @@ class RealstarConnector(BaseConnector):
173 161 self.max_renders = self._renders + self.max_renders_on
174 162 html = self._rendered(ON_SEARCH_URL, 12000)
175 163 soup = BeautifulSoup(html, "html.parser")
176 − cards = soup.select("li.property-box")
164 + cards = soup.select('li[class*="property-box"]')
177 165 if not cards: # rendu incomplet : une seconde chance
178 166 html = self._rendered(ON_SEARCH_URL, 15000)
179 167 soup = BeautifulSoup(html, "html.parser")
180 − cards = soup.select("li.property-box")
168 + cards = soup.select('li[class*="property-box"]')
181 169
182 170 listings: list[Listing] = []
183 171 count = 0
@@ -277,7 +265,7 @@ class RealstarConnector(BaseConnector):
277 265 if img and img.get("src"):
278 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 269 # carte liste a changé (prix/dispo inclus dans le hash).
282 270 key = hashlib.sha1(
283 271 f"{name}|{address}|{beds}|{baths}|{sqft}|{price_label}"
@@ -362,7 +350,7 @@ class RealstarConnector(BaseConnector):
362 350
363 351 # -- pages détail (fiche propriété + plans) --------------------------------
364 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 354 /floorplans (plans structurés). Appelé seulement hors cache."""
367 355 if self._renders + 2 > self.max_renders:
368 356 raise _BudgetReached()
modified louka/connectors/via_capitale.py +5 −2
@@ -3,7 +3,7 @@
3 3 # Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 4 # connectors/via_capitale.py : Via Capitale (viacapitalevendu.com) — LOCATIONS
5 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 7 # franchit le challenge — testé : Scrapfly ASP passe Cloudflare mais ne rend
8 8 # pas les cartes. La recherche accepte un blob base64 `criteresJson` ;
9 9 # « AVendre »: false y bascule le même moteur en mode LOCATION (prix
@@ -61,7 +61,10 @@ CRITERES_JSON = base64.b64encode(
61 61 IMG_HOST = "https://images.viacapitale.info"
62 62
63 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 68 PRICE_RE = re.compile(r'class="price">\s*([\d ]+\$\s*/\s*mois)', re.I)
66 69 ADDR_RE = re.compile(r'addressListe[^>]*>\s*<a[^>]*title="([^"]+)"', re.I)
67 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 106 inst.session.mount("https://", adapter)
107 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 111 orig_rendered = inst.get_rendered
111 112
112 − def _rec_rendered(url: str) -> str:
113 − html = orig_rendered(url)
113 + def _rec_rendered(url: str, *args, **kw) -> str:
114 + html = orig_rendered(url, *args, **kw)
114 115 key = _req_key("RENDERED", url, None)
115 116 fname = key + ".html"
116 117 (out_dir / fname).write_text(
@@ -193,7 +194,7 @@ def replay_connector(source_id: str):
193 194 inst.session.mount("https://", adapter)
194 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 198 key = _req_key("RENDERED", url, None)
198 199 meta = index.get(key)
199 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 147 detailed.append("Scrapfly (asp + render_js — contournement anti-bot)")
148 148 family = "Scrapfly"
149 149 if "get_rendered(" in text:
150 − detailed.append("Firecrawl (HTML rendu, JavaScript exécuté)")
151 − family = "Firecrawl" if family == "direct" else family
150 + detailed.append("Scrapfly render_js (HTML rendu, JavaScript exécuté"
151 + " — ex-Firecrawl, migré 2026-08-27)")
152 + family = "Scrapfly" if family == "direct" else family
152 153 if not detailed:
153 154 detailed.append("requests direct")
154 155 return detailed, family
@@ -388,9 +389,7 @@ def render_fiche(entry: dict, reg: list[dict], stats: dict, now: str) -> str:
388 389 + " · ".join(entry["urls"]))
389 390 out.append("- **Authentification** : aucune — contenu public"
390 391 + (" (clé Scrapfly côté Lou-Ka)" if entry["backend_family"] ==
391 − "Scrapfly" else
392 − " (clé Firecrawl côté Lou-Ka)" if entry["backend_family"] ==
393 − "Firecrawl" else ""))
392 + "Scrapfly" else ""))
394 393 out.append(f"- **Pagination** : {entry['pagination']}")
395 394 out.append(f"- **Backend anti-bot / rendu** : {' ; '.join(entry['backends'])}")
396 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