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