Python 68.8%
TypeScript 18.6%
CSS 8.7%
JavaScript 3.3%
HTML 0.6%
1# -----------------------------------------------------------------------------2# Rent-Ka — Rental listings aggregator (Canada, outside Québec)3# Author: Simon-Pierre Boucher — contact@spboucher.ai4# serpapi_client.py : client SerpApi minimal, STRICTEMENT côté serveur.5#6# Sécurité de la clé (non négociable) :7# - lue UNIQUEMENT depuis la variable d'environnement SERPAPI_API_KEY8# (chargée par run.py depuis .env, jamais commitée — voir .env.example) ;9# - jamais écrite dans les logs, les exceptions ni les réponses API ;10# - aucune requête SerpApi ne part du navigateur : ce module n'est importé11# que par les jobs serveur (managers.py).12#13# Robustesse : timeout 30 s, 3 tentatives avec repli exponentiel sur les14# erreurs transitoires (réseau, 429, 5xx). Les erreurs définitives (4xx)15# remontent immédiatement.16# -----------------------------------------------------------------------------17from __future__ import annotations1819import os20import time2122import requests2324BASE = "https://serpapi.com/search.json"25TIMEOUT = 3026RETRIES = 327BACKOFF = 2.0 # s — doublé à chaque tentative282930class SerpApiError(RuntimeError):31 pass323334def api_key() -> str | None:35 return os.environ.get("SERPAPI_API_KEY") or None363738def available() -> bool:39 return api_key() is not None404142def search(params: dict) -> dict:43 """GET serpapi.com/search.json — params SANS la clé (ajoutée ici)."""44 key = api_key()45 if not key:46 raise SerpApiError("SERPAPI_API_KEY absente de l'environnement")47 q = dict(params)48 q["api_key"] = key49 last = "erreur inconnue"50 for attempt in range(RETRIES):51 if attempt:52 time.sleep(BACKOFF * (2 ** (attempt - 1)))53 try:54 r = requests.get(BASE, params=q, timeout=TIMEOUT)55 except requests.RequestException as e:56 # ne jamais inclure l'URL complète (elle contient la clé)57 last = f"erreur réseau: {type(e).__name__}"58 continue59 if r.status_code == 429 or r.status_code >= 500:60 last = f"HTTP {r.status_code}"61 continue62 if r.status_code != 200:63 raise SerpApiError(f"SerpApi HTTP {r.status_code}")64 data = r.json()65 if data.get("error"):66 # « fully empty » = zéro résultat, pas une panne67 if "hasn't returned any results" in str(data["error"]):68 return data69 raise SerpApiError(f"SerpApi: {data['error']}")70 return data71 raise SerpApiError(f"SerpApi injoignable après {RETRIES} tentatives ({last})")727374def google_maps(query: str) -> dict:75 return search({"engine": "google_maps", "q": query, "hl": "fr",76 "gl": "ca", "google_domain": "google.ca", "type": "search"})777879def google_maps_reviews(data_id: str, next_page_token: str | None = None) -> dict:80 p = {"engine": "google_maps_reviews", "data_id": data_id,81 "hl": "fr", "sort_by": "newestFirst"}82 if next_page_token:83 # num n'est accepté par SerpApi qu'avec un next_page_token84 p["next_page_token"] = next_page_token85 p["num"] = 2086 return search(p)87