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