# ----------------------------------------------------------------------------- # Rent-Ka — Rental listings aggregator (Canada, outside Québec) # Author: Simon-Pierre Boucher — contact@spboucher.ai # serpapi_client.py : client SerpApi minimal, STRICTEMENT côté serveur. # # Sécurité de la clé (non négociable) : # - lue UNIQUEMENT depuis la variable d'environnement SERPAPI_API_KEY # (chargée par run.py depuis .env, jamais commitée — voir .env.example) ; # - jamais écrite dans les logs, les exceptions ni les réponses API ; # - aucune requête SerpApi ne part du navigateur : ce module n'est importé # que par les jobs serveur (managers.py). # # Robustesse : timeout 30 s, 3 tentatives avec repli exponentiel sur les # erreurs transitoires (réseau, 429, 5xx). Les erreurs définitives (4xx) # remontent immédiatement. # ----------------------------------------------------------------------------- from __future__ import annotations import os import time import requests BASE = "https://serpapi.com/search.json" TIMEOUT = 30 RETRIES = 3 BACKOFF = 2.0 # s — doublé à chaque tentative class SerpApiError(RuntimeError): pass def api_key() -> str | None: return os.environ.get("SERPAPI_API_KEY") or None def available() -> bool: return api_key() is not None def search(params: dict) -> dict: """GET serpapi.com/search.json — params SANS la clé (ajoutée ici).""" key = api_key() if not key: raise SerpApiError("SERPAPI_API_KEY absente de l'environnement") q = dict(params) q["api_key"] = key last = "erreur inconnue" for attempt in range(RETRIES): if attempt: time.sleep(BACKOFF * (2 ** (attempt - 1))) try: r = requests.get(BASE, params=q, timeout=TIMEOUT) except requests.RequestException as e: # ne jamais inclure l'URL complète (elle contient la clé) last = f"erreur réseau: {type(e).__name__}" continue if r.status_code == 429 or r.status_code >= 500: last = f"HTTP {r.status_code}" continue if r.status_code != 200: raise SerpApiError(f"SerpApi HTTP {r.status_code}") data = r.json() if data.get("error"): # « fully empty » = zéro résultat, pas une panne if "hasn't returned any results" in str(data["error"]): return data raise SerpApiError(f"SerpApi: {data['error']}") return data raise SerpApiError(f"SerpApi injoignable après {RETRIES} tentatives ({last})") def google_maps(query: str) -> dict: return search({"engine": "google_maps", "q": query, "hl": "fr", "gl": "ca", "google_domain": "google.ca", "type": "search"}) def google_maps_reviews(data_id: str, next_page_token: str | None = None) -> dict: p = {"engine": "google_maps_reviews", "data_id": data_id, "hl": "fr", "sort_by": "newestFirst"} if next_page_token: # num n'est accepté par SerpApi qu'avec un next_page_token p["next_page_token"] = next_page_token p["num"] = 20 return search(p)