# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # fixtures.py : enregistrement/rejeu des réponses HTTP des sources. # `python run.py record ` capture toutes les réponses (HTML/JSON) # dans tests/fixtures// + un instantané `expected.json` du parsing. # Les tests (tests/test_connectors.py) rejouent ces fixtures hors-ligne : # si un connecteur régresse, le test casse — sans toucher au site réel. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import json import re from pathlib import Path import requests from requests.adapters import BaseAdapter, HTTPAdapter from .schema import Listing ROOT = Path(__file__).resolve().parent.parent FIXTURES_DIR = ROOT / "tests" / "fixtures" class MissingFixture(Exception): """Le connecteur a demandé une URL absente des fixtures enregistrées.""" # Secrets tiers présents dans le HTML public des sites sources (clés Google # Maps, jetons) : caviardés à l'enregistrement — les fixtures sont publiées # sur GitHub et ces clés n'ont aucun rôle dans le parsing. _SECRET_RES = [ (re.compile(rb"AIza[0-9A-Za-z_-]{35}"), b"AIza_CLE_CAVIARDEE_LOUKA_000000000000000"), # jetons Mapbox (sk. = secret, pk. = public) embarqués par certains sites (re.compile(rb"(sk|pk)\.eyJ[A-Za-z0-9_.=-]+"), rb"\1.JETON_CAVIARDE_LOUKA"), ] def _redact(content: bytes) -> bytes: for rx, repl in _SECRET_RES: content = rx.sub(repl, content) return content def _req_key(method: str, url: str, body: bytes | str | None) -> str: if isinstance(body, str): body = body.encode("utf-8") h = hashlib.sha1() h.update(f"{method.upper()} {url} ".encode()) if body: h.update(body) return h.hexdigest()[:20] def _ext_for(content_type: str) -> str: if "json" in content_type: return ".json" if "html" in content_type: return ".html" return ".txt" # --------------------------------------------------------------------------- # Enregistrement # --------------------------------------------------------------------------- class _RecordAdapter(HTTPAdapter): def __init__(self, store: dict, out_dir: Path): super().__init__() self.store = store self.out_dir = out_dir def send(self, request, **kwargs): # type: ignore[override] resp = super().send(request, **kwargs) key = _req_key(request.method or "GET", request.url or "", request.body) ctype = resp.headers.get("Content-Type", "") fname = key + _ext_for(ctype) (self.out_dir / fname).write_bytes(_redact(resp.content)) self.store[key] = { "method": request.method, "url": request.url, "status": resp.status_code, "content_type": ctype, "file": fname, } if resp.headers.get("Location"): self.store[key]["location"] = resp.headers["Location"] return resp def record(source_id: str) -> dict: """Exécute le connecteur en enregistrant chaque réponse HTTP en fixture. Écrit tests/fixtures//{index.json, expected.json, *.html|json}. Retourne un résumé {source, requests, listings}. """ from .connectors import CONNECTORS cls = CONNECTORS[source_id] out_dir = FIXTURES_DIR / source_id out_dir.mkdir(parents=True, exist_ok=True) for old in out_dir.iterdir(): old.unlink() inst = cls() inst.use_detail_cache = False # capturer aussi les pages détail store: dict = {} adapter = _RecordAdapter(store, out_dir) inst.session.mount("https://", adapter) inst.session.mount("http://", adapter) # get_rendered (Firecrawl) : enregistrer le HTML rendu, clé = URL cible orig_rendered = inst.get_rendered def _rec_rendered(url: str) -> str: html = orig_rendered(url) key = _req_key("RENDERED", url, None) fname = key + ".html" (out_dir / fname).write_text( _redact(html.encode("utf-8")).decode("utf-8"), encoding="utf-8") store[key] = {"method": "RENDERED", "url": url, "status": 200, "content_type": "text/html", "file": fname} return html inst.get_rendered = _rec_rendered # type: ignore[method-assign] listings = inst.fetch() (out_dir / "index.json").write_text( json.dumps(store, ensure_ascii=False, indent=1), encoding="utf-8") (out_dir / "expected.json").write_text( json.dumps(snapshot(listings), ensure_ascii=False, indent=1), encoding="utf-8") return {"source": source_id, "requests": len(store), "listings": len(listings)} def snapshot(listings: list[Listing]) -> dict: """Instantané compact et déterministe du parsing (champs bruts, pré-finalize). La date de disponibilité normalisée dépend du jour d'exécution : on ne snapshotte que les champs stables pour éviter les tests fragiles. """ rows = [] for l in sorted(listings, key=lambda x: x.uid): rows.append({ "uid": l.uid, "url": l.url, "title": l.title, "address": l.address, "sector": l.sector, "city": l.city, "unit_type": l.unit_type, "price": l.price, "availability": l.availability, "area_sqft": l.area_sqft, "n_images": len(l.images), "n_amenities": len(l.amenities), }) return {"count": len(rows), "listings": rows} # --------------------------------------------------------------------------- # Rejeu # --------------------------------------------------------------------------- class _ReplayAdapter(BaseAdapter): def __init__(self, index: dict, fixtures_dir: Path): super().__init__() self.index = index self.dir = fixtures_dir def send(self, request, **kwargs): # type: ignore[override] key = _req_key(request.method or "GET", request.url or "", request.body) meta = self.index.get(key) if meta is None: raise MissingFixture(f"{request.method} {request.url}") resp = requests.models.Response() resp.status_code = meta["status"] resp._content = (self.dir / meta["file"]).read_bytes() resp.headers["Content-Type"] = meta.get("content_type", "") if meta.get("location"): resp.headers["Location"] = meta["location"] resp.url = request.url or "" resp.request = request resp.encoding = "utf-8" return resp def close(self): pass def replay_connector(source_id: str): """Instancie le connecteur branché sur ses fixtures (aucun réseau).""" from .connectors import CONNECTORS cls = CONNECTORS[source_id] fdir = FIXTURES_DIR / source_id index = json.loads((fdir / "index.json").read_text(encoding="utf-8")) inst = cls() inst.request_delay = 0.0 # pas de politesse hors-ligne inst.use_detail_cache = False # tests indépendants de la BD adapter = _ReplayAdapter(index, fdir) inst.session.mount("https://", adapter) inst.session.mount("http://", adapter) def _replay_rendered(url: str) -> str: key = _req_key("RENDERED", url, None) meta = index.get(key) if meta is None: raise MissingFixture(f"RENDERED {url}") return (fdir / meta["file"]).read_text(encoding="utf-8") inst.get_rendered = _replay_rendered # type: ignore[method-assign] return inst def recorded_sources() -> list[str]: """Sources ayant des fixtures enregistrées (pour paramétrer les tests).""" if not FIXTURES_DIR.exists(): return [] return sorted(p.name for p in FIXTURES_DIR.iterdir() if (p / "index.json").exists())