[ka2] snapshot pré-mission immobilia
12 changed files +1,697 −1
added
.env.example
+29 −0
@@ -0,0 +1,29 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — variables d'environnement (modèle — copier vers .env, NE PAS COMMITTER .env) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# ----------------------------------------------------------------------------- | |
| 5 | + | |
| 6 | +# Connecteurs / scraping | |
| 7 | +FIRECRAWL_API_KEY= | |
| 8 | +SCRAPFLY_KEY= | |
| 9 | +APIFY_TOKEN= | |
| 10 | +OXYLABS_PROXY_USER= | |
| 11 | +OXYLABS_PROXY_PASS= | |
| 12 | +BRIGHTDATA_API_KEY= | |
| 13 | +BRIGHTDATA_ZONE= | |
| 14 | + | |
| 15 | +# Session / auth | |
| 16 | +SESSION_SECRET= | |
| 17 | +LOUKA_BASE_URL=https://www.lou-ka.com | |
| 18 | +GOOGLE_CLIENT_ID= | |
| 19 | +GOOGLE_CLIENT_SECRET= | |
| 20 | +KA_SSO_SECRET= | |
| 21 | +KA_HUB_URL=https://www.groupe-ka.com | |
| 22 | + | |
| 23 | +# Hydro-Québec (estimation électricité — solveur reCAPTCHA) | |
| 24 | +HQ_CAPTCHA_KEY= | |
| 25 | +HQ_CAPTCHA_PROVIDER=2captcha | |
| 26 | + | |
| 27 | +# SerpApi — résolution Google Maps + avis des gestionnaires (louka/managers.py) | |
| 28 | +# Server-side SEULEMENT : jamais exposée au frontend ni écrite dans les logs. | |
| 29 | +SERPAPI_API_KEY= | |
added
louka/building.py
+244 −0
@@ -0,0 +1,244 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# building.py : « Passeport de l'immeuble » — entité persistante regroupant les | |
| 5 | +# annonces (actives ET historiques) d'un même bâtiment. | |
| 6 | +# | |
| 7 | +# Clé d'immeuble : la clé d'adresse civique de dedup._parse_address | |
| 8 | +# (« civique|rue|ville », préfixe « adr: ») — la même logique éprouvée que la | |
| 9 | +# déduplication inter-sources ; repli « geo:coord_key » (~11 m) quand l'adresse | |
| 10 | +# n'a pas de numéro civique fiable mais que l'annonce est géocodée. | |
| 11 | +# | |
| 12 | +# Tout indicateur est calculé UNIQUEMENT sur ce que Lou-Ka observe réellement | |
| 13 | +# (annonces collectées par les crawls). Un indicateur sans échantillon | |
| 14 | +# suffisant est omis ou marqué « donnees_insuffisantes » — jamais inventé. | |
| 15 | +# ----------------------------------------------------------------------------- | |
| 16 | +from __future__ import annotations | |
| 17 | + | |
| 18 | +import json | |
| 19 | +import statistics | |
| 20 | +import time | |
| 21 | + | |
| 22 | +from . import db | |
| 23 | +from .dedup import CLASSIFIEDS, PORTALS, _parse_address | |
| 24 | + | |
| 25 | +VERSION = "immeuble-1.0" | |
| 26 | + | |
| 27 | +# seuils du score de rotation (annonces des 12 derniers mois / unités estimées) | |
| 28 | +ROTATION = [(0.5, "faible"), (1.0, "normale"), (2.0, "élevée")] | |
| 29 | +ROTATION_MAX = "très élevée" | |
| 30 | +MIN_OBS_DAYS = 180 # fenêtre d'observation minimale pour classer la rotation | |
| 31 | +MIN_ADS_ROTATION = 3 # nb d'annonces minimal pour classer la rotation | |
| 32 | +MIN_ADS_PRESSURE = 3 # nb minimal par fenêtre pour la pression sur les loyers | |
| 33 | + | |
| 34 | + | |
| 35 | +def building_key(address: str | None, city: str | None, | |
| 36 | + coord_key: str | None) -> tuple[str | None, str | None]: | |
| 37 | + """(clé d'immeuble, numéro d'unité) — adresse d'abord, géo en repli.""" | |
| 38 | + akey, unit = _parse_address(address or "", city or "") | |
| 39 | + if akey: | |
| 40 | + return f"adr:{akey}", unit | |
| 41 | + if coord_key: | |
| 42 | + return f"geo:{coord_key}", unit | |
| 43 | + return None, unit | |
| 44 | + | |
| 45 | + | |
| 46 | +def _median(vals: list[float]) -> float | None: | |
| 47 | + vals = [v for v in vals if v] | |
| 48 | + return round(statistics.median(vals)) if vals else None | |
| 49 | + | |
| 50 | + | |
| 51 | +def _stats_for(members: list[dict], now: float) -> dict: | |
| 52 | + """Statistiques d'un immeuble à partir de ses annonces (obs. Lou-Ka).""" | |
| 53 | + d30, d90, d365, d730 = (now - 30 * 86400, now - 90 * 86400, | |
| 54 | + now - 365 * 86400, now - 730 * 86400) | |
| 55 | + actives = [m for m in members if m["active"]] | |
| 56 | + first_obs = min(m["first_seen"] for m in members if m["first_seen"]) | |
| 57 | + last_obs = max(m["last_seen"] for m in members if m["last_seen"]) | |
| 58 | + obs_days = max(1, round((now - first_obs) / 86400)) | |
| 59 | + | |
| 60 | + units = {m["unit"] for m in members if m["unit"]} | |
| 61 | + # unités estimées : numéros d'app distincts observés, plancher = annonces | |
| 62 | + # actives en ce moment (elles existent forcément toutes en même temps) | |
| 63 | + unites_estimees = max(len(units), len(actives), 1) | |
| 64 | + | |
| 65 | + n_12m = sum(1 for m in members if (m["first_seen"] or 0) >= d365) | |
| 66 | + | |
| 67 | + # loyers médians (annonces actives) | |
| 68 | + med = _median([m["price"] for m in actives]) | |
| 69 | + par_cc: dict[str, int] = {} | |
| 70 | + by_cc: dict[str, list[float]] = {} | |
| 71 | + for m in actives: | |
| 72 | + if m["price"] and m["bedrooms"] is not None: | |
| 73 | + by_cc.setdefault(str(int(m["bedrooms"])), []).append(m["price"]) | |
| 74 | + for cc, vals in sorted(by_cc.items()): | |
| 75 | + v = _median(vals) | |
| 76 | + if v and len(vals) >= 2: | |
| 77 | + par_cc[cc] = v | |
| 78 | + vals_pi2 = [m["price"] / m["area"] for m in actives | |
| 79 | + if m["price"] and m["area"] and m["area"] > 100] | |
| 80 | + pi2 = round(statistics.median(vals_pi2), 2) if vals_pi2 else None | |
| 81 | + | |
| 82 | + # pression sur les loyers : médiane des PREMIERS prix observés des annonces | |
| 83 | + # apparues dans les 12 derniers mois vs celles des 12 mois précédents | |
| 84 | + win_new = [m["first_price"] for m in members | |
| 85 | + if m["first_price"] and (m["first_seen"] or 0) >= d365] | |
| 86 | + win_old = [m["first_price"] for m in members | |
| 87 | + if m["first_price"] and d730 <= (m["first_seen"] or 0) < d365] | |
| 88 | + pression = None | |
| 89 | + if len(win_new) >= MIN_ADS_PRESSURE and len(win_old) >= MIN_ADS_PRESSURE: | |
| 90 | + m_new, m_old = statistics.median(win_new), statistics.median(win_old) | |
| 91 | + if m_old > 0: | |
| 92 | + pression = { | |
| 93 | + "variation_12m": round((m_new - m_old) / m_old, 4), | |
| 94 | + "mediane_12m": round(m_new), "mediane_12_24m": round(m_old), | |
| 95 | + "n_12m": len(win_new), "n_12_24m": len(win_old), | |
| 96 | + "statut": "calculated", | |
| 97 | + } | |
| 98 | + | |
| 99 | + # score de rotation : annonces des 12 derniers mois / unités estimées | |
| 100 | + rotation: dict = {"statut": "donnees_insuffisantes"} | |
| 101 | + if obs_days >= MIN_OBS_DAYS and len(members) >= MIN_ADS_ROTATION: | |
| 102 | + ratio = n_12m / unites_estimees | |
| 103 | + classe = ROTATION_MAX | |
| 104 | + for seuil, nom in ROTATION: | |
| 105 | + if ratio < seuil: | |
| 106 | + classe = nom | |
| 107 | + break | |
| 108 | + rotation = { | |
| 109 | + "classe": classe, "ratio": round(ratio, 2), | |
| 110 | + "annonces_12m": n_12m, "unites_estimees": unites_estimees, | |
| 111 | + "observation_jours": obs_days, "statut": "calculated", | |
| 112 | + "methode": ("annonces observées les 12 derniers mois ÷ unités " | |
| 113 | + "estimées (numéros d'app distincts, plancher = annonces " | |
| 114 | + "actives simultanées). Mesure uniquement ce que Lou-Ka " | |
| 115 | + "observe — pas un taux de roulement démographique."), | |
| 116 | + } | |
| 117 | + | |
| 118 | + sources: dict[str, int] = {} | |
| 119 | + for m in members: | |
| 120 | + sources[m["source"]] = sources.get(m["source"], 0) + 1 | |
| 121 | + gestionnaires = sorted(s for s in sources | |
| 122 | + if s not in PORTALS and s not in CLASSIFIEDS) | |
| 123 | + | |
| 124 | + return { | |
| 125 | + "annonces_total": len(members), | |
| 126 | + "annonces_actives": len(actives), | |
| 127 | + "annonces_30j": sum(1 for m in members if (m["first_seen"] or 0) >= d30), | |
| 128 | + "annonces_90j": sum(1 for m in members if (m["first_seen"] or 0) >= d90), | |
| 129 | + "annonces_12m": n_12m, | |
| 130 | + "unites_identifiees": len(units), | |
| 131 | + "unites_estimees": unites_estimees, | |
| 132 | + "loyer_median": med, | |
| 133 | + "loyer_median_par_cc": par_cc or None, | |
| 134 | + "pi2_median": pi2, | |
| 135 | + "pression_loyers": pression, | |
| 136 | + "rotation": rotation, | |
| 137 | + "gestionnaires": gestionnaires, | |
| 138 | + "sources": sources, | |
| 139 | + "premiere_observation": first_obs, | |
| 140 | + "derniere_observation": last_obs, | |
| 141 | + "unites_actives": sorted( | |
| 142 | + [{"uid": m["uid"], "unit_type": m["unit_type"], | |
| 143 | + "price": m["price"], "bedrooms": m["bedrooms"]} | |
| 144 | + for m in actives if not m["dup_of"]], | |
| 145 | + key=lambda x: (x["price"] is None, x["price"] or 0))[:24], | |
| 146 | + } | |
| 147 | + | |
| 148 | + | |
| 149 | +def rollup(con=None) -> dict: | |
| 150 | + """Recalcule building_key de toutes les annonces + la table buildings. | |
| 151 | + | |
| 152 | + Idempotent, recalcul complet (même philosophie que dedup.run) : les | |
| 153 | + annonces INACTIVES participent — l'historique de l'immeuble est justement | |
| 154 | + ce qui fait la valeur du passeport. | |
| 155 | + """ | |
| 156 | + own = con is None | |
| 157 | + if own: | |
| 158 | + con = db.connect() | |
| 159 | + now = time.time() | |
| 160 | + rows = con.execute( | |
| 161 | + "SELECT uid, source, address, city, coord_key, unit_type, bedrooms," | |
| 162 | + " price, area_sqft, first_seen, last_seen, active, dup_of, building_key" | |
| 163 | + " FROM listings").fetchall() | |
| 164 | + | |
| 165 | + # premier prix observé par annonce (pression sur les loyers) | |
| 166 | + firstp = {r["uid"]: r["p"] for r in con.execute( | |
| 167 | + "SELECT uid, (SELECT price FROM price_log p2 WHERE p2.uid=p1.uid" | |
| 168 | + " AND p2.price IS NOT NULL ORDER BY ts LIMIT 1) p" | |
| 169 | + " FROM (SELECT DISTINCT uid FROM price_log) p1")} | |
| 170 | + | |
| 171 | + groups: dict[str, list[dict]] = {} | |
| 172 | + key_updates: list[tuple[str | None, str]] = [] | |
| 173 | + for r in rows: | |
| 174 | + bkey, unit = building_key(r["address"], r["city"], r["coord_key"]) | |
| 175 | + if bkey != r["building_key"]: | |
| 176 | + key_updates.append((bkey, r["uid"])) | |
| 177 | + if not bkey: | |
| 178 | + continue | |
| 179 | + groups.setdefault(bkey, []).append({ | |
| 180 | + "uid": r["uid"], "source": r["source"], "unit": unit, | |
| 181 | + "unit_type": r["unit_type"], "bedrooms": r["bedrooms"], | |
| 182 | + "price": r["price"], "area": r["area_sqft"], | |
| 183 | + "first_seen": r["first_seen"], "last_seen": r["last_seen"], | |
| 184 | + "active": r["active"], "dup_of": r["dup_of"], | |
| 185 | + "first_price": firstp.get(r["uid"]) or r["price"], | |
| 186 | + "address": r["address"], "city": r["city"], | |
| 187 | + }) | |
| 188 | + if key_updates: | |
| 189 | + con.executemany("UPDATE listings SET building_key=? WHERE uid=?", | |
| 190 | + key_updates) | |
| 191 | + | |
| 192 | + upserts = [] | |
| 193 | + for bkey, members in groups.items(): | |
| 194 | + # doublons inter-sources : un même logement publié sur 3 plateformes ne | |
| 195 | + # compte qu'une fois dans les statistiques de l'immeuble | |
| 196 | + uniq = [m for m in members if not m["dup_of"]] | |
| 197 | + if not uniq: | |
| 198 | + continue | |
| 199 | + stats = _stats_for(uniq, now) | |
| 200 | + # adresse d'affichage : la plus fréquente parmi les membres actifs | |
| 201 | + addrs = [m["address"] for m in uniq if m["address"]] | |
| 202 | + addr = max(set(addrs), key=addrs.count) if addrs else None | |
| 203 | + cities = [m["city"] for m in uniq if m["city"]] | |
| 204 | + city = max(set(cities), key=cities.count) if cities else None | |
| 205 | + # position : coord de n'importe quel membre géocodé (via coord_key) | |
| 206 | + upserts.append((bkey, addr, city, | |
| 207 | + json.dumps(stats, ensure_ascii=False), VERSION, now)) | |
| 208 | + con.executemany( | |
| 209 | + "INSERT INTO buildings (bkey, address, city, stats, version, computed_at)" | |
| 210 | + " VALUES (?,?,?,?,?,?)" | |
| 211 | + " ON CONFLICT(bkey) DO UPDATE SET address=excluded.address," | |
| 212 | + " city=excluded.city, stats=excluded.stats, version=excluded.version," | |
| 213 | + " computed_at=excluded.computed_at", upserts) | |
| 214 | + con.commit() | |
| 215 | + out = {"buildings": len(upserts), "listings_scanned": len(rows), | |
| 216 | + "keys_updated": len(key_updates)} | |
| 217 | + if own: | |
| 218 | + con.close() | |
| 219 | + return out | |
| 220 | + | |
| 221 | + | |
| 222 | +def fiche(bkey: str, con=None) -> dict | None: | |
| 223 | + """Passeport d'un immeuble pour l'API (stats précalculées par rollup).""" | |
| 224 | + own = con is None | |
| 225 | + if own: | |
| 226 | + con = db.connect() | |
| 227 | + row = con.execute( | |
| 228 | + "SELECT bkey, address, city, stats, version, computed_at FROM buildings" | |
| 229 | + " WHERE bkey=?", (bkey,)).fetchone() | |
| 230 | + if own and row is None: | |
| 231 | + con.close() | |
| 232 | + return None | |
| 233 | + if row is None: | |
| 234 | + return None | |
| 235 | + out = {"bkey": row["bkey"], "address": row["address"], "city": row["city"], | |
| 236 | + "version": row["version"], "computed_at": row["computed_at"], | |
| 237 | + **json.loads(row["stats"] or "{}")} | |
| 238 | + if own: | |
| 239 | + con.close() | |
| 240 | + return out | |
| 241 | + | |
| 242 | + | |
| 243 | +if __name__ == "__main__": | |
| 244 | + print(rollup()) | |
added
louka/coutreel.py
+196 −0
@@ -0,0 +1,196 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# coutreel.py : « Coût réel mensuel Lou-Ka » — loyer + frais non inclus, | |
| 5 | +# ligne par ligne, chaque poste étiqueté : | |
| 6 | +# included : explicitement inclus dans le bail (details.inclusions) | |
| 7 | +# observed : montant affiché par la source (ex. prix du stationnement) | |
| 8 | +# estimated : estimation sourcée (Hydro-Québec via hydro.py, médiane Internet) | |
| 9 | +# unknown : ni inclus ni estimable — dit explicitement, jamais inventé | |
| 10 | +# | |
| 11 | +# + prix au pi² avec percentile RÉELLEMENT calculé sur les comparables | |
| 12 | +# (même ville + même nombre de chambres, et rayon ~2 km si géocodée). | |
| 13 | +# ----------------------------------------------------------------------------- | |
| 14 | +from __future__ import annotations | |
| 15 | + | |
| 16 | +import json | |
| 17 | + | |
| 18 | +from . import db | |
| 19 | + | |
| 20 | +# Internet résidentiel : médiane des forfaits de base au Québec (ordre de | |
| 21 | +# grandeur public CRTC/fournisseurs). Estimation générique, PAS une donnée | |
| 22 | +# de l'adresse — toujours présentée comme telle. | |
| 23 | +INTERNET_ESTIME = 60.0 | |
| 24 | + | |
| 25 | +VERSION = "cout-reel-1.0" | |
| 26 | + | |
| 27 | + | |
| 28 | +def _pctile(values: list[float], x: float) -> int: | |
| 29 | + below = sum(1 for v in values if v < x) | |
| 30 | + return round(100 * below / len(values)) | |
| 31 | + | |
| 32 | + | |
| 33 | +def pi2(con, l: dict) -> dict | None: | |
| 34 | + """Prix au pi² + percentile parmi les comparables (calcul réel).""" | |
| 35 | + price, area = l.get("price"), l.get("area_sqft") | |
| 36 | + if not price or not area or area < 100: | |
| 37 | + return None | |
| 38 | + val = price / area | |
| 39 | + out: dict = {"valeur": round(val, 2), "statut": "calculated"} | |
| 40 | + | |
| 41 | + base = ("SELECT price, area_sqft, lat, lng FROM listings" | |
| 42 | + " WHERE active=1 AND published=1 AND dup_of IS NULL" | |
| 43 | + " AND price IS NOT NULL AND area_sqft > 100 AND uid<>?") | |
| 44 | + args: list = [l["uid"]] | |
| 45 | + if l.get("city"): | |
| 46 | + base += " AND city=?"; args.append(l["city"]) | |
| 47 | + if l.get("bedrooms") is not None: | |
| 48 | + base += " AND bedrooms=?"; args.append(l["bedrooms"]) | |
| 49 | + elif l.get("unit_type"): | |
| 50 | + base += " AND unit_type=?"; args.append(l["unit_type"]) | |
| 51 | + rows = con.execute(base, args).fetchall() | |
| 52 | + ville = [r["price"] / r["area_sqft"] for r in rows] | |
| 53 | + if len(ville) >= 12: | |
| 54 | + out["percentile_ville"] = _pctile(ville, val) | |
| 55 | + out["n_ville"] = len(ville) | |
| 56 | + out["portee_ville"] = (f"{l.get('city')}, " | |
| 57 | + + (f"{int(l['bedrooms'])} ch." | |
| 58 | + if l.get("bedrooms") is not None | |
| 59 | + else l.get("unit_type") or "tous types")) | |
| 60 | + lat, lng = l.get("lat"), l.get("lng") | |
| 61 | + if lat is not None and lng is not None: | |
| 62 | + secteur = [r["price"] / r["area_sqft"] for r in rows | |
| 63 | + if r["lat"] is not None | |
| 64 | + and abs(r["lat"] - lat) < 0.018 and abs(r["lng"] - lng) < 0.025] | |
| 65 | + if len(secteur) >= 12: | |
| 66 | + out["percentile_secteur"] = _pctile(secteur, val) | |
| 67 | + out["n_secteur"] = len(secteur) | |
| 68 | + if "percentile_ville" not in out and "percentile_secteur" not in out: | |
| 69 | + out["percentile_note"] = "comparables insuffisants pour un percentile" | |
| 70 | + return out | |
| 71 | + | |
| 72 | + | |
| 73 | +def compute(uid: str, con=None) -> dict | None: | |
| 74 | + """Coût réel mensuel d'une annonce — module « Coût réel Lou-Ka ».""" | |
| 75 | + own = con is None | |
| 76 | + if own: | |
| 77 | + con = db.connect() | |
| 78 | + try: | |
| 79 | + row = con.execute( | |
| 80 | + "SELECT uid, price, area_sqft, city, bedrooms, unit_type, lat, lng," | |
| 81 | + " details, address FROM listings WHERE uid=?", (uid,)).fetchone() | |
| 82 | + if row is None: | |
| 83 | + return None | |
| 84 | + l = dict(row) | |
| 85 | + try: | |
| 86 | + details = json.loads(l.get("details") or "{}") or {} | |
| 87 | + except (ValueError, TypeError): | |
| 88 | + details = {} | |
| 89 | + inc = details.get("inclusions") or {} | |
| 90 | + price = l.get("price") | |
| 91 | + | |
| 92 | + lignes: list[dict] = [] | |
| 93 | + lignes.append({"poste": "Loyer affiché", "statut": "observed", | |
| 94 | + "montant": price, "source": "annonce"}) | |
| 95 | + | |
| 96 | + # --- électricité + chauffage (souvent le même compteur au Québec) ---- | |
| 97 | + hydro_est = None | |
| 98 | + if not inc.get("electricity"): | |
| 99 | + from . import hydro | |
| 100 | + h = hydro.estimate(uid=uid, adresse=l.get("address"), | |
| 101 | + lat=l.get("lat"), lng=l.get("lng"), solve=False) | |
| 102 | + if h.get("disponible") and h.get("cout_mensuel"): | |
| 103 | + hydro_est = round(float(h["cout_mensuel"])) | |
| 104 | + if inc.get("electricity"): | |
| 105 | + lignes.append({"poste": "Électricité", "statut": "included", | |
| 106 | + "montant": 0, "source": "annonce (incluse)"}) | |
| 107 | + elif inc.get("electricity") is False: | |
| 108 | + lignes.append({"poste": "Électricité", | |
| 109 | + "statut": "estimated" if hydro_est else "unknown", | |
| 110 | + "montant": hydro_est, | |
| 111 | + "source": ("outil public d'estimation Hydro-Québec" | |
| 112 | + if hydro_est else None), | |
| 113 | + "note": None if hydro_est else | |
| 114 | + "explicitement à la charge du locataire, " | |
| 115 | + "montant non estimable pour cette adresse"}) | |
| 116 | + else: | |
| 117 | + lignes.append({"poste": "Électricité", | |
| 118 | + "statut": "estimated" if hydro_est else "unknown", | |
| 119 | + "montant": hydro_est, | |
| 120 | + "source": ("outil public d'estimation Hydro-Québec" | |
| 121 | + if hydro_est else None), | |
| 122 | + "note": None if hydro_est else | |
| 123 | + "l'annonce ne précise pas si elle est incluse"}) | |
| 124 | + | |
| 125 | + if inc.get("heating"): | |
| 126 | + lignes.append({"poste": "Chauffage", "statut": "included", | |
| 127 | + "montant": 0, "source": "annonce (inclus)"}) | |
| 128 | + elif hydro_est: | |
| 129 | + lignes.append({"poste": "Chauffage", "statut": "estimated", | |
| 130 | + "montant": None, | |
| 131 | + "note": "chauffage électrique répandu au Québec — " | |
| 132 | + "déjà compté dans l'estimation d'électricité"}) | |
| 133 | + else: | |
| 134 | + lignes.append({"poste": "Chauffage", "statut": "unknown", | |
| 135 | + "montant": None, | |
| 136 | + "note": "ni inclus ni estimable avec les données " | |
| 137 | + "disponibles"}) | |
| 138 | + | |
| 139 | + if inc.get("hot_water"): | |
| 140 | + lignes.append({"poste": "Eau chaude", "statut": "included", | |
| 141 | + "montant": 0, "source": "annonce (incluse)"}) | |
| 142 | + else: | |
| 143 | + lignes.append({"poste": "Eau chaude", "statut": "unknown", | |
| 144 | + "montant": None, | |
| 145 | + "note": "l'annonce ne précise pas si elle est incluse"}) | |
| 146 | + | |
| 147 | + if inc.get("internet"): | |
| 148 | + lignes.append({"poste": "Internet", "statut": "included", | |
| 149 | + "montant": 0, "source": "annonce (inclus)"}) | |
| 150 | + else: | |
| 151 | + lignes.append({"poste": "Internet", "statut": "estimated", | |
| 152 | + "montant": INTERNET_ESTIME, | |
| 153 | + "source": "médiane des forfaits résidentiels de base " | |
| 154 | + "au Québec (estimation générique)"}) | |
| 155 | + | |
| 156 | + parking = details.get("parking") or {} | |
| 157 | + if parking.get("available"): | |
| 158 | + if parking.get("included"): | |
| 159 | + lignes.append({"poste": "Stationnement", "statut": "included", | |
| 160 | + "montant": 0, "source": "annonce (inclus)"}) | |
| 161 | + elif parking.get("price"): | |
| 162 | + lignes.append({"poste": "Stationnement", "statut": "observed", | |
| 163 | + "montant": float(parking["price"]), | |
| 164 | + "source": "annonce (prix affiché)"}) | |
| 165 | + else: | |
| 166 | + lignes.append({"poste": "Stationnement", "statut": "unknown", | |
| 167 | + "montant": None, | |
| 168 | + "note": "disponible, prix non précisé par la source"}) | |
| 169 | + | |
| 170 | + # --- totaux : jamais de montant inventé ------------------------------ | |
| 171 | + total = None | |
| 172 | + if price: | |
| 173 | + total = float(price) + sum( | |
| 174 | + (li["montant"] or 0) for li in lignes[1:] | |
| 175 | + if li["statut"] in ("observed", "estimated") | |
| 176 | + and li["montant"] is not None) | |
| 177 | + inconnues = [li["poste"] for li in lignes if li["statut"] == "unknown"] | |
| 178 | + | |
| 179 | + out = { | |
| 180 | + "uid": uid, "version": VERSION, | |
| 181 | + "loyer": price, | |
| 182 | + "lignes": lignes, | |
| 183 | + "total_estime": round(total) if total else None, | |
| 184 | + "annuel_estime": round(total * 12) if total else None, | |
| 185 | + "postes_inconnus": inconnues, | |
| 186 | + "pi2": pi2(con, l), | |
| 187 | + "methode": ("loyer affiché + frais non inclus connus (observés) ou " | |
| 188 | + "estimés avec source ; les postes inconnus sont listés " | |
| 189 | + "tels quels, jamais chiffrés arbitrairement"), | |
| 190 | + } | |
| 191 | + if total and l.get("area_sqft") and l["area_sqft"] > 100: | |
| 192 | + out["total_pi2"] = round(total / l["area_sqft"], 2) | |
| 193 | + return out | |
| 194 | + finally: | |
| 195 | + if own: | |
| 196 | + con.close() | |
modified
louka/db.py
+140 −1
@@ -174,6 +174,80 @@ CREATE TABLE IF NOT EXISTS source_profiles ( | ||
| 174 | 174 | logo_url TEXT, |
| 175 | 175 | updated_at REAL |
| 176 | 176 | ); |
| 177 | + | |
| 178 | +CREATE TABLE IF NOT EXISTS listing_events ( | |
| 179 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 180 | + uid TEXT NOT NULL, -- listings.uid | |
| 181 | + ts REAL NOT NULL, | |
| 182 | + event TEXT NOT NULL, -- description | superficie | dispo | | |
| 183 | + -- inclusions | photos | disparition | | |
| 184 | + -- reapparition (prix -> price_log) | |
| 185 | + old TEXT, -- valeur avant (JSON compact) | |
| 186 | + new TEXT -- valeur après (JSON compact) | |
| 187 | +); | |
| 188 | +CREATE INDEX IF NOT EXISTS idx_levents_uid ON listing_events(uid, ts); | |
| 189 | + | |
| 190 | +CREATE TABLE IF NOT EXISTS buildings ( | |
| 191 | + bkey TEXT PRIMARY KEY, -- clé d'adresse « civique|rue|ville » | |
| 192 | + -- (dedup._parse_address) ou « geo:lat,lng » | |
| 193 | + address TEXT, -- adresse d'affichage (la plus fréquente) | |
| 194 | + city TEXT, | |
| 195 | + lat REAL, lng REAL, | |
| 196 | + stats TEXT, -- JSON : unités, annonces, loyers, | |
| 197 | + -- rotation, pression, gestionnaires… | |
| 198 | + version TEXT, -- version du calcul (building.py) | |
| 199 | + computed_at REAL | |
| 200 | +); | |
| 201 | + | |
| 202 | +CREATE TABLE IF NOT EXISTS recycled_cache ( | |
| 203 | + uid TEXT PRIMARY KEY, -- listings.uid | |
| 204 | + matches TEXT, -- JSON : correspondances probables + signaux | |
| 205 | + computed_at REAL | |
| 206 | +); | |
| 207 | + | |
| 208 | +CREATE TABLE IF NOT EXISTS managers ( | |
| 209 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 210 | + source_id TEXT UNIQUE, -- id de la source (data/sources.json) | |
| 211 | + name TEXT, -- nom canonique | |
| 212 | + aliases TEXT, -- JSON : variantes de nom | |
| 213 | + website TEXT, | |
| 214 | + phone TEXT, | |
| 215 | + -- résolution Google Maps (SerpApi) — voir louka/managers.py | |
| 216 | + gmaps_place_id TEXT, | |
| 217 | + gmaps_data_id TEXT, | |
| 218 | + gmaps_name TEXT, | |
| 219 | + gmaps_address TEXT, | |
| 220 | + gmaps_rating REAL, | |
| 221 | + gmaps_reviews INTEGER, -- nombre d'avis annoncé par Google | |
| 222 | + match_confidence REAL, -- 0..1 (association refusée si trop faible) | |
| 223 | + match_signals TEXT, -- JSON : signaux ayant fondé l'association | |
| 224 | + resolved_at REAL, | |
| 225 | + resolve_failed INTEGER DEFAULT 0, | |
| 226 | + review_stats TEXT, -- JSON : distribution, tendance, thèmes | |
| 227 | + last_synced_at REAL | |
| 228 | +); | |
| 229 | + | |
| 230 | +CREATE TABLE IF NOT EXISTS manager_reviews ( | |
| 231 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 232 | + manager_id INTEGER NOT NULL, | |
| 233 | + external_review_id TEXT, -- id Google (déduplication) | |
| 234 | + source TEXT DEFAULT 'google_maps', | |
| 235 | + rating REAL, | |
| 236 | + text TEXT, | |
| 237 | + published_at TEXT, -- ISO si dérivable | |
| 238 | + relative_date_raw TEXT, -- « il y a 3 mois » (texte source) | |
| 239 | + author_name TEXT, | |
| 240 | + author_review_count INTEGER, | |
| 241 | + owner_response TEXT, | |
| 242 | + owner_response_date TEXT, | |
| 243 | + source_url TEXT, | |
| 244 | + fetched_at REAL, | |
| 245 | + content_hash TEXT, -- déduplication de secours | |
| 246 | + analysis TEXT -- JSON : topics/sentiment/severity (managers.py) | |
| 247 | +); | |
| 248 | +CREATE INDEX IF NOT EXISTS idx_mreviews_mgr ON manager_reviews(manager_id); | |
| 249 | +CREATE UNIQUE INDEX IF NOT EXISTS idx_mreviews_ext | |
| 250 | + ON manager_reviews(manager_id, external_review_id); | |
| 177 | 251 | """ |
| 178 | 252 | |
| 179 | 253 | # Colonnes ajoutées après la v1 — migration automatique des bases existantes. |
@@ -198,6 +272,7 @@ _MIGRATIONS = { | ||
| 198 | 272 | "images_ok": "TEXT", # JSON : galerie nettoyée (imgcheck.py) |
| 199 | 273 | "img_audit": "TEXT", # JSON : traçabilité du contrôle images |
| 200 | 274 | "coord_key": "TEXT", # clé immeuble « lat,lng » 4 déc. (jointure kascores) |
| 275 | + "building_key": "TEXT", # clé immeuble par adresse (buildings.bkey) | |
| 201 | 276 | }, |
| 202 | 277 | "sync_log": { |
| 203 | 278 | "stats": "TEXT", |
@@ -235,6 +310,8 @@ def connect() -> sqlite3.Connection: | ||
| 235 | 310 | " ON listings(active, published)") |
| 236 | 311 | con.execute("CREATE INDEX IF NOT EXISTS idx_listings_coord" |
| 237 | 312 | " ON listings(coord_key)") |
| 313 | + con.execute("CREATE INDEX IF NOT EXISTS idx_listings_bkey" | |
| 314 | + " ON listings(building_key)") | |
| 238 | 315 | con.commit() |
| 239 | 316 | # WAL : lectures (web) et écritures (sync, imgcheck) concurrentes sans |
| 240 | 317 | # verrou global ; busy_timeout évite les « database is locked » ponctuels. |
@@ -276,6 +353,54 @@ def _drift_alert(con: sqlite3.Connection, source: str, found: int, | ||
| 276 | 353 | return None |
| 277 | 354 | |
| 278 | 355 | |
| 356 | +def _j(v) -> str: | |
| 357 | + return json.dumps(v, ensure_ascii=False) | |
| 358 | + | |
| 359 | + | |
| 360 | +def _log_events(con: sqlite3.Connection, uid: str, ts: float, | |
| 361 | + old: sqlite3.Row, new: dict) -> None: | |
| 362 | + """Journalise les changements observés d'une annonce (listing_events). | |
| 363 | + | |
| 364 | + Le prix a déjà son historique dédié (price_log) — ici : description, | |
| 365 | + superficie, disponibilité, inclusions, photos. Valeurs compactes (pas de | |
| 366 | + texte intégral) : l'objectif est la timeline de la fiche, pas l'archive. | |
| 367 | + """ | |
| 368 | + ev: list[tuple[str, str | None, str | None]] = [] | |
| 369 | + old_desc = (old["description"] or "").strip() | |
| 370 | + new_desc = (new.get("description") or "").strip() | |
| 371 | + if old_desc != new_desc and (old_desc or new_desc): | |
| 372 | + ev.append(("description", _j({"caracteres": len(old_desc)}), | |
| 373 | + _j({"caracteres": len(new_desc)}))) | |
| 374 | + if (old["area_sqft"] or None) != (new.get("area_sqft") or None): | |
| 375 | + ev.append(("superficie", _j(old["area_sqft"]), _j(new.get("area_sqft")))) | |
| 376 | + if (old["availability_date"] or None) != (new.get("availability_date") or None): | |
| 377 | + ev.append(("dispo", _j(old["availability_date"]), | |
| 378 | + _j(new.get("availability_date")))) | |
| 379 | + try: | |
| 380 | + inc_old = (json.loads(old["details"] or "{}") or {}).get("inclusions") or {} | |
| 381 | + inc_new = (json.loads(new.get("details") or "{}") or {}).get("inclusions") or {} | |
| 382 | + except (ValueError, TypeError): | |
| 383 | + inc_old = inc_new = {} | |
| 384 | + if inc_old != inc_new: | |
| 385 | + keys = set(inc_old) | set(inc_new) | |
| 386 | + diff_old = {k: inc_old.get(k) for k in keys if inc_old.get(k) != inc_new.get(k)} | |
| 387 | + diff_new = {k: inc_new.get(k) for k in keys if inc_old.get(k) != inc_new.get(k)} | |
| 388 | + if diff_old or diff_new: | |
| 389 | + ev.append(("inclusions", _j(diff_old), _j(diff_new))) | |
| 390 | + try: | |
| 391 | + imgs_old = json.loads(old["images"] or "[]") | |
| 392 | + imgs_new = json.loads(new.get("images") or "[]") | |
| 393 | + except (ValueError, TypeError): | |
| 394 | + imgs_old = imgs_new = [] | |
| 395 | + if set(imgs_old) != set(imgs_new): | |
| 396 | + ev.append(("photos", _j({"n": len(imgs_old)}), _j({"n": len(imgs_new)}))) | |
| 397 | + if ev: | |
| 398 | + con.executemany( | |
| 399 | + "INSERT INTO listing_events (uid, ts, event, old, new)" | |
| 400 | + " VALUES (?,?,?,?,?)", | |
| 401 | + [(uid, ts, e, o, n) for e, o, n in ev]) | |
| 402 | + | |
| 403 | + | |
| 279 | 404 | def sync_source(con: sqlite3.Connection, source: str, |
| 280 | 405 | listings: list[Listing]) -> dict: |
| 281 | 406 | """Synchronise les annonces d'une source. |
@@ -301,7 +426,8 @@ def sync_source(con: sqlite3.Connection, source: str, | ||
| 301 | 426 | seen_uids.add(lst.uid) |
| 302 | 427 | h = lst.content_hash() |
| 303 | 428 | row = con.execute( |
| 304 | − "SELECT content_hash, price, images FROM listings WHERE uid=?", | |
| 429 | + "SELECT content_hash, price, images, description, area_sqft," | |
| 430 | + " availability_date, details, active FROM listings WHERE uid=?", | |
| 305 | 431 | (lst.uid,)).fetchone() |
| 306 | 432 | # score de complétude + seuil de publication (quarantaine sous le seuil) |
| 307 | 433 | from .quality import evaluate |
@@ -382,8 +508,18 @@ def sync_source(con: sqlite3.Connection, source: str, | ||
| 382 | 508 | if lst.price != row["price"]: # changement de prix -> historique |
| 383 | 509 | con.execute("INSERT INTO price_log (uid, ts, price) VALUES (?,?,?)", |
| 384 | 510 | (lst.uid, now, lst.price)) |
| 511 | + # timeline de la fiche : changements observés (hors prix) | |
| 512 | + _log_events(con, lst.uid, now, row, params) | |
| 513 | + if not row["active"]: | |
| 514 | + con.execute("INSERT INTO listing_events (uid, ts, event, old, new)" | |
| 515 | + " VALUES (?,?,?,?,?)", | |
| 516 | + (lst.uid, now, "reapparition", None, None)) | |
| 385 | 517 | updated += 1 |
| 386 | 518 | else: |
| 519 | + if not row["active"]: | |
| 520 | + con.execute("INSERT INTO listing_events (uid, ts, event, old, new)" | |
| 521 | + " VALUES (?,?,?,?,?)", | |
| 522 | + (lst.uid, now, "reapparition", None, None)) | |
| 387 | 523 | con.execute( |
| 388 | 524 | "UPDATE listings SET last_seen=?, miss_count=0, active=1 WHERE uid=?", |
| 389 | 525 | (now, lst.uid)) |
@@ -402,6 +538,9 @@ def sync_source(con: sqlite3.Connection, source: str, | ||
| 402 | 538 | con.execute( |
| 403 | 539 | "UPDATE listings SET active=0, miss_count=?, updated_at=?" |
| 404 | 540 | " WHERE uid=?", (r["miss_count"] + 1, now, r["uid"])) |
| 541 | + con.execute("INSERT INTO listing_events (uid, ts, event, old, new)" | |
| 542 | + " VALUES (?,?,?,?,?)", | |
| 543 | + (r["uid"], now, "disparition", None, None)) | |
| 405 | 544 | removed += 1 |
| 406 | 545 | else: |
| 407 | 546 | con.execute("UPDATE listings SET miss_count=miss_count+1 WHERE uid=?", |
added
louka/history.py
+95 −0
@@ -0,0 +1,95 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# history.py : « Historique Lou-Ka » d'une annonce — timeline construite à | |
| 5 | +# partir des observations réelles des crawls : | |
| 6 | +# - price_log (depuis la v1) : chaque changement de prix observé ; | |
| 7 | +# - listing_events (db.py/_log_events) : description, superficie, dispo, | |
| 8 | +# inclusions, photos, disparition/réapparition. | |
| 9 | +# Chaque élément est étiqueté `observed` : rien n'est estimé ni inventé. | |
| 10 | +# ----------------------------------------------------------------------------- | |
| 11 | +from __future__ import annotations | |
| 12 | + | |
| 13 | +import json | |
| 14 | +import time | |
| 15 | + | |
| 16 | +from . import db | |
| 17 | + | |
| 18 | + | |
| 19 | +def timeline(uid: str, con=None) -> dict | None: | |
| 20 | + """Historique complet d'une annonce (fiche + API). | |
| 21 | + | |
| 22 | + Retourne : première/dernière observation, jours en ligne, prix initial et | |
| 23 | + courant, variation, nombre de modifications et la timeline fusionnée | |
| 24 | + (prix + événements), du plus récent au plus ancien. | |
| 25 | + """ | |
| 26 | + own = con is None | |
| 27 | + if own: | |
| 28 | + con = db.connect() | |
| 29 | + row = con.execute( | |
| 30 | + "SELECT uid, price, first_seen, last_seen, active FROM listings" | |
| 31 | + " WHERE uid=?", (uid,)).fetchone() | |
| 32 | + if row is None: | |
| 33 | + if own: | |
| 34 | + con.close() | |
| 35 | + return None | |
| 36 | + | |
| 37 | + prices = con.execute( | |
| 38 | + "SELECT ts, price FROM price_log WHERE uid=? ORDER BY ts", (uid,)).fetchall() | |
| 39 | + events = con.execute( | |
| 40 | + "SELECT ts, event, old, new FROM listing_events WHERE uid=? ORDER BY ts", | |
| 41 | + (uid,)).fetchall() | |
| 42 | + | |
| 43 | + items: list[dict] = [] | |
| 44 | + prev_price = None | |
| 45 | + for p in prices: | |
| 46 | + it = {"ts": p["ts"], "type": "prix", "prix": p["price"], | |
| 47 | + "statut": "observed"} | |
| 48 | + if prev_price is not None and p["price"] is not None: | |
| 49 | + it["prix_avant"] = prev_price | |
| 50 | + items.append(it) | |
| 51 | + if p["price"] is not None: | |
| 52 | + prev_price = p["price"] | |
| 53 | + for e in events: | |
| 54 | + def _load(v): | |
| 55 | + try: | |
| 56 | + return json.loads(v) if v is not None else None | |
| 57 | + except (ValueError, TypeError): | |
| 58 | + return None | |
| 59 | + items.append({"ts": e["ts"], "type": e["event"], | |
| 60 | + "avant": _load(e["old"]), "apres": _load(e["new"]), | |
| 61 | + "statut": "observed"}) | |
| 62 | + items.sort(key=lambda x: x["ts"], reverse=True) | |
| 63 | + | |
| 64 | + first_price = next((p["price"] for p in prices if p["price"] is not None), None) | |
| 65 | + cur_price = row["price"] | |
| 66 | + variation = None | |
| 67 | + if first_price and cur_price and first_price > 0: | |
| 68 | + variation = round((cur_price - first_price) / first_price, 4) | |
| 69 | + | |
| 70 | + now = time.time() | |
| 71 | + fin = row["last_seen"] if not row["active"] else now | |
| 72 | + jours = max(0, round((fin - (row["first_seen"] or fin)) / 86400)) | |
| 73 | + | |
| 74 | + # modifications = éléments de timeline hors point de départ du prix | |
| 75 | + n_modif = max(0, len([p for p in prices]) - 1) + len( | |
| 76 | + [e for e in events if e["event"] not in ("disparition", "reapparition")]) | |
| 77 | + | |
| 78 | + out = { | |
| 79 | + "uid": uid, | |
| 80 | + "premiere_observation": row["first_seen"], | |
| 81 | + "derniere_observation": row["last_seen"], | |
| 82 | + "active": bool(row["active"]), | |
| 83 | + "jours_en_ligne": jours, | |
| 84 | + "prix_initial": first_price, | |
| 85 | + "prix_actuel": cur_price, | |
| 86 | + "variation": variation, | |
| 87 | + "modifications": n_modif, | |
| 88 | + "timeline": items[:60], | |
| 89 | + "methode": ("Observations directes des synchronisations Lou-Ka " | |
| 90 | + "(prix, description, superficie, disponibilité, inclusions, " | |
| 91 | + "photos, retraits/retours). Aucune estimation."), | |
| 92 | + } | |
| 93 | + if own: | |
| 94 | + con.close() | |
| 95 | + return out | |
added
louka/hiver.py
+108 −0
@@ -0,0 +1,108 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# hiver.py : « Vie quotidienne en hiver » — indicateur 0-100 pensé pour le | |
| 5 | +# Québec : peut-on vivre son quotidien À PIED quand il fait -20 °C | |
| 6 | +# et que les trottoirs sont enneigés? | |
| 7 | +# | |
| 8 | +# Calcul déterministe et explicable à partir des données DÉJÀ collectées | |
| 9 | +# (poi_cache — plus proche commodité par catégorie, OpenStreetMap) : | |
| 10 | +# épicerie, pharmacie, arrêt de bus/métro, dépanneur. Chaque sous-score est | |
| 11 | +# une décroissance linéaire de la distance de marche (vol d'oiseau × 1,3). | |
| 12 | +# Pas de données de pente/exposition disponibles -> non pris en compte, et la | |
| 13 | +# méthodologie l'assume. Sans données POI : aucun score (jamais inventé). | |
| 14 | +# ----------------------------------------------------------------------------- | |
| 15 | +from __future__ import annotations | |
| 16 | + | |
| 17 | +import json | |
| 18 | + | |
| 19 | +from . import db | |
| 20 | + | |
| 21 | +VERSION = "hiver-1.0" | |
| 22 | +DETOUR = 1.3 # vol d'oiseau -> distance de marche (même facteur que POI) | |
| 23 | +VITESSE_M_MIN = 80.0 # 4,8 km/h | |
| 24 | + | |
| 25 | +# (catégorie, libellé, poids, distance pleine note (m), distance note nulle (m)) | |
| 26 | +CRITERES = [ | |
| 27 | + ("epicerie", "Épicerie", 0.35, 300, 1500), | |
| 28 | + ("pharmacie", "Pharmacie", 0.20, 300, 1500), | |
| 29 | + ("bus", "Arrêt de bus", 0.25, 150, 900), | |
| 30 | + ("metro", "Métro", 0.05, 400, 1800), | |
| 31 | + ("depanneur", "Dépanneur", 0.15, 200, 1000), | |
| 32 | +] | |
| 33 | + | |
| 34 | +CLASSES = [(80, "très pratique"), (60, "pratique"), (40, "exigeant")] | |
| 35 | +CLASSE_MIN = "difficile" | |
| 36 | + | |
| 37 | + | |
| 38 | +def _sous_score(dist_m: float, plein: float, nul: float) -> float: | |
| 39 | + d = dist_m * DETOUR | |
| 40 | + if d <= plein: | |
| 41 | + return 100.0 | |
| 42 | + if d >= nul: | |
| 43 | + return 0.0 | |
| 44 | + return 100.0 * (nul - d) / (nul - plein) | |
| 45 | + | |
| 46 | + | |
| 47 | +def score(lat: float | None, lng: float | None, con=None) -> dict | None: | |
| 48 | + """Score hiver de l'immeuble (via poi_cache) — None si données absentes.""" | |
| 49 | + if lat is None or lng is None: | |
| 50 | + return None | |
| 51 | + own = con is None | |
| 52 | + if own: | |
| 53 | + con = db.connect() | |
| 54 | + try: | |
| 55 | + key = f"{round(lat, 4)},{round(lng, 4)}" | |
| 56 | + row = con.execute("SELECT pois FROM poi_cache WHERE coord_key=?", | |
| 57 | + (key,)).fetchone() | |
| 58 | + if row is None: | |
| 59 | + return None | |
| 60 | + try: | |
| 61 | + pois = {p["cat"]: p for p in json.loads(row["pois"] or "[]")} | |
| 62 | + except (ValueError, TypeError): | |
| 63 | + return None | |
| 64 | + if not pois: | |
| 65 | + return None | |
| 66 | + | |
| 67 | + total = 0.0 | |
| 68 | + poids_total = 0.0 | |
| 69 | + detail = [] | |
| 70 | + for cat, label, poids, plein, nul in CRITERES: | |
| 71 | + p = pois.get(cat) | |
| 72 | + if p is None: | |
| 73 | + # métro absent = normal hors Montréal : critère simplement omis | |
| 74 | + if cat == "metro": | |
| 75 | + continue | |
| 76 | + s = 0.0 | |
| 77 | + item = {"critere": label, "score": 0, "distance_m": None, | |
| 78 | + "note": "aucun repéré à distance de marche"} | |
| 79 | + else: | |
| 80 | + s = _sous_score(p["dist_m"], plein, nul) | |
| 81 | + item = {"critere": label, "score": round(s), | |
| 82 | + "distance_m": p["dist_m"], | |
| 83 | + "minutes": max(1, round(p["dist_m"] * DETOUR | |
| 84 | + / VITESSE_M_MIN)), | |
| 85 | + "nom": p.get("name")} | |
| 86 | + total += s * poids | |
| 87 | + poids_total += poids | |
| 88 | + detail.append(item) | |
| 89 | + if poids_total == 0: | |
| 90 | + return None | |
| 91 | + note = round(total / poids_total) | |
| 92 | + classe = CLASSE_MIN | |
| 93 | + for seuil, nom in CLASSES: | |
| 94 | + if note >= seuil: | |
| 95 | + classe = nom | |
| 96 | + break | |
| 97 | + return { | |
| 98 | + "score": note, "classe": classe, "detail": detail, | |
| 99 | + "statut": "calculated", "version": VERSION, | |
| 100 | + "methode": ("commodités essentielles à distance de marche " | |
| 101 | + "(épicerie, pharmacie, bus/métro, dépanneur — " | |
| 102 | + "OpenStreetMap), décroissance linéaire de la distance " | |
| 103 | + "de marche estimée (vol d'oiseau × 1,3). Pente et " | |
| 104 | + "exposition non disponibles : non prises en compte."), | |
| 105 | + } | |
| 106 | + finally: | |
| 107 | + if own: | |
| 108 | + con.close() | |
modified
louka/ingest.py
+14 −0
@@ -124,6 +124,12 @@ def watch(interval_seconds: int = 3600) -> None: | ||
| 124 | 124 | print(f"[lou-ka] dedup: {stats}") |
| 125 | 125 | except Exception as exc: |
| 126 | 126 | print(f"[lou-ka] dedup: erreur non bloquante: {exc}", file=sys.stderr) |
| 127 | + try: # passeport des immeubles — après dedup (utilise dup_of + adresse) | |
| 128 | + from . import building | |
| 129 | + stats_b = building.rollup() | |
| 130 | + print(f"[lou-ka] immeubles: {stats_b}") | |
| 131 | + except Exception as exc: | |
| 132 | + print(f"[lou-ka] immeubles: erreur non bloquante: {exc}", file=sys.stderr) | |
| 127 | 133 | try: # juste valeur locative (fair value) — après dedup/geocode/quality |
| 128 | 134 | from . import fairvalue |
| 129 | 135 | fairvalue.compute_all() |
@@ -148,6 +154,14 @@ def watch(interval_seconds: int = 3600) -> None: | ||
| 148 | 154 | tal.precompute(limit=60, budget=600) |
| 149 | 155 | except Exception as exc: |
| 150 | 156 | print(f"[lou-ka] tal: erreur non bloquante: {exc}", file=sys.stderr) |
| 157 | + try: # gestionnaires : résolution Google Maps + avis (SerpApi, | |
| 158 | + # serveur seulement, budget borné ; sauté sans SERPAPI_API_KEY) | |
| 159 | + from . import managers | |
| 160 | + stats_m = managers.precompute(budget_s=120) | |
| 161 | + print(f"[lou-ka] gestionnaires: {stats_m}") | |
| 162 | + except Exception as exc: | |
| 163 | + print(f"[lou-ka] gestionnaires: erreur non bloquante: {exc}", | |
| 164 | + file=sys.stderr) | |
| 151 | 165 | print(f"[lou-ka] prochaine synchronisation dans {interval_seconds}s") |
| 152 | 166 | time.sleep(interval_seconds) |
| 153 | 167 | |
added
louka/managers.py
+529 −0
@@ -0,0 +1,529 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# managers.py : « Qui gère ce logement? » — fiche du gestionnaire/propriétaire. | |
| 5 | +# | |
| 6 | +# 1) Amorçage : les gestionnaires sont les sources DIRECTES de Lou-Ka | |
| 7 | +# (data/sources.json, hors portails et petites annonces) qui ont au moins | |
| 8 | +# une annonce collectée. Aucun gestionnaire n'est inventé. | |
| 9 | +# 2) Résolution Google Maps (SerpApi, serveur seulement) : la fiche Google | |
| 10 | +# n'est associée QUE si la confiance est suffisante (similarité de nom, | |
| 11 | +# domaine du site web, ville) — jamais « le premier résultat » aveuglément. | |
| 12 | +# Signaux et confiance stockés (match_confidence, match_signals). | |
| 13 | +# 3) Avis Google : synchronisation paginée (next_page_token, hl=fr, plus | |
| 14 | +# récents d'abord), dédupliqués par (manager_id, review_id Google) avec | |
| 15 | +# repli sur un hash de contenu. Les avis vivent dans NOTRE base : les pages | |
| 16 | +# ne déclenchent jamais d'appel SerpApi. | |
| 17 | +# 4) Analyse : statistiques réelles (distribution, récence, tendance — pas | |
| 18 | +# seulement la moyenne Google) + thèmes par lexique français, sentiment | |
| 19 | +# dérivé de la note, sévérité et confiance. Statut « inferred » assumé. | |
| 20 | +# ----------------------------------------------------------------------------- | |
| 21 | +from __future__ import annotations | |
| 22 | + | |
| 23 | +import difflib | |
| 24 | +import hashlib | |
| 25 | +import json | |
| 26 | +import re | |
| 27 | +import time | |
| 28 | +import unicodedata | |
| 29 | +from datetime import datetime, timezone | |
| 30 | + | |
| 31 | +from . import db, serpapi_client | |
| 32 | +from .dedup import CLASSIFIEDS, PORTALS | |
| 33 | + | |
| 34 | +VERSION = "gestionnaire-1.0" | |
| 35 | +SEUIL_CONFIANCE = 0.60 # sous ce seuil : fiche Google NON associée | |
| 36 | +MAX_PAGES = 10 # garde-fou pagination avis (par synchronisation) | |
| 37 | +SYNC_TTL = 7 * 86400 # re-synchroniser les avis au plus aux 7 jours | |
| 38 | +RESOLVE_RETRY = 30 * 86400 # re-tenter une résolution échouée après 30 jours | |
| 39 | + | |
| 40 | +# suffixes/génériques retirés pour comparer les noms d'entreprises | |
| 41 | +_LEGAL = re.compile( | |
| 42 | + r"\b(inc|ltee|ltée|ltd|senc|s\.e\.n\.c|enr|corp|corporation|groupe|group|" | |
| 43 | + r"gestion|gestions|immobilier|immobiliere|immobilière|immeubles?|" | |
| 44 | + r"appartements?|apartments?|properties|proprietes|propriétés|residences?|" | |
| 45 | + r"résidences?|habitations?|siege|social|bureau|head|office|" | |
| 46 | + r"les|le|la|de|du|des|et|and)\b") | |
| 47 | + | |
| 48 | +# types Google d'une fiche d'ENTREPRISE (vs fiche d'un immeuble précis) | |
| 49 | +_CORP_TYPES = re.compile( | |
| 50 | + r"agence immobili|gestion immobili|property management|real estate|" | |
| 51 | + r"societe de gestion|immobilier", re.I) | |
| 52 | + | |
| 53 | + | |
| 54 | +def _norm_name(s: str | None) -> str: | |
| 55 | + s = unicodedata.normalize("NFKD", (s or "").lower()) | |
| 56 | + s = "".join(c for c in s if not unicodedata.combining(c)) | |
| 57 | + s = re.sub(r"[^a-z0-9 ]+", " ", s) | |
| 58 | + s = _LEGAL.sub(" ", s) | |
| 59 | + return " ".join(s.split()) | |
| 60 | + | |
| 61 | + | |
| 62 | +def _domain(url: str | None) -> str | None: | |
| 63 | + if not url: | |
| 64 | + return None | |
| 65 | + m = re.search(r"^(?:https?://)?(?:www\.)?([^/:?#]+)", url.strip().lower()) | |
| 66 | + return m.group(1) if m else None | |
| 67 | + | |
| 68 | + | |
| 69 | +# --- 1) amorçage depuis sources.json ---------------------------------------- | |
| 70 | + | |
| 71 | +def seed(con=None) -> dict: | |
| 72 | + """Crée/actualise un gestionnaire par source directe ayant des annonces.""" | |
| 73 | + own = con is None | |
| 74 | + if own: | |
| 75 | + con = db.connect() | |
| 76 | + try: | |
| 77 | + sources = json.loads((db.DB_PATH.parent / "sources.json") | |
| 78 | + .read_text("utf-8"))["sources"] | |
| 79 | + with_ads = {r["source"] for r in con.execute( | |
| 80 | + "SELECT DISTINCT source FROM listings")} | |
| 81 | + n = 0 | |
| 82 | + for s in sources: | |
| 83 | + sid = s.get("id") | |
| 84 | + if not sid or sid in PORTALS or sid in CLASSIFIEDS: | |
| 85 | + continue | |
| 86 | + if sid not in with_ads and s.get("connector") not in with_ads: | |
| 87 | + continue | |
| 88 | + con.execute( | |
| 89 | + "INSERT INTO managers (source_id, name, website, aliases)" | |
| 90 | + " VALUES (?,?,?,?)" | |
| 91 | + " ON CONFLICT(source_id) DO UPDATE SET name=excluded.name," | |
| 92 | + " website=excluded.website", | |
| 93 | + (sid, s.get("name"), s.get("url"), | |
| 94 | + json.dumps({"region": s.get("region"), | |
| 95 | + "connector": s.get("connector")}, | |
| 96 | + ensure_ascii=False))) | |
| 97 | + n += 1 | |
| 98 | + con.commit() | |
| 99 | + return {"managers": n} | |
| 100 | + finally: | |
| 101 | + if own: | |
| 102 | + con.close() | |
| 103 | + | |
| 104 | + | |
| 105 | +# --- 2) résolution Google Maps (jamais « le premier résultat ») -------------- | |
| 106 | + | |
| 107 | +def _score_candidate(mgr: dict, cand: dict) -> tuple[float, dict]: | |
| 108 | + signals: dict = {} | |
| 109 | + sim = difflib.SequenceMatcher( | |
| 110 | + None, _norm_name(mgr["name"]), _norm_name(cand.get("title"))).ratio() | |
| 111 | + signals["similarite_nom"] = round(sim, 3) | |
| 112 | + conf = 0.6 * sim | |
| 113 | + d_mgr, d_cand = _domain(mgr.get("website")), _domain(cand.get("website")) | |
| 114 | + if d_mgr and d_cand and d_mgr == d_cand: | |
| 115 | + conf += 0.3 | |
| 116 | + signals["meme_domaine_web"] = d_mgr | |
| 117 | + region = None | |
| 118 | + try: | |
| 119 | + region = (json.loads(mgr.get("aliases") or "{}") or {}).get("region") | |
| 120 | + except (ValueError, TypeError): | |
| 121 | + pass | |
| 122 | + addr = (cand.get("address") or "").lower() | |
| 123 | + if region and _norm_name(region) and _norm_name(region) in _norm_name(addr): | |
| 124 | + conf += 0.1 | |
| 125 | + signals["ville_correspondante"] = region | |
| 126 | + if _CORP_TYPES.search(cand.get("type") or ""): | |
| 127 | + signals["fiche_entreprise"] = cand.get("type") | |
| 128 | + signals["type_fiche"] = cand.get("type") | |
| 129 | + signals["nombre_avis"] = cand.get("reviews") | |
| 130 | + return min(1.0, conf), signals | |
| 131 | + | |
| 132 | + | |
| 133 | +def resolve(manager_id: int, con=None) -> dict: | |
| 134 | + """Associe (ou refuse d'associer) la fiche Google Maps du gestionnaire.""" | |
| 135 | + own = con is None | |
| 136 | + if own: | |
| 137 | + con = db.connect() | |
| 138 | + try: | |
| 139 | + mgr = con.execute("SELECT * FROM managers WHERE id=?", | |
| 140 | + (manager_id,)).fetchone() | |
| 141 | + if mgr is None: | |
| 142 | + return {"ok": False, "raison": "gestionnaire inconnu"} | |
| 143 | + mgr = dict(mgr) | |
| 144 | + region = "" | |
| 145 | + try: | |
| 146 | + region = (json.loads(mgr.get("aliases") or "{}") or {}).get( | |
| 147 | + "region") or "" | |
| 148 | + except (ValueError, TypeError): | |
| 149 | + pass | |
| 150 | + q = f"{mgr['name']} {region} Québec".strip() | |
| 151 | + data = serpapi_client.google_maps(q) | |
| 152 | + cands = data.get("local_results") or [] | |
| 153 | + if not cands and data.get("place_results"): | |
| 154 | + cands = [data["place_results"]] | |
| 155 | + | |
| 156 | + scored = [( *_score_candidate(mgr, c), c) for c in cands[:8]] | |
| 157 | + best, best_conf, best_sig = None, 0.0, {} | |
| 158 | + if scored: | |
| 159 | + top = max(s[0] for s in scored) | |
| 160 | + # une chaîne a souvent une fiche Google PAR IMMEUBLE en plus du | |
| 161 | + # siège : parmi les candidats plausibles (confiance proche du | |
| 162 | + # meilleur ET au-dessus du seuil), préférer la fiche d'entreprise | |
| 163 | + # puis la plus représentative (le plus d'avis) | |
| 164 | + near = [s for s in scored | |
| 165 | + if s[0] >= max(SEUIL_CONFIANCE, top - 0.15)] | |
| 166 | + if near: | |
| 167 | + best_conf, best_sig, best = max( | |
| 168 | + near, key=lambda s: (bool(_CORP_TYPES.search( | |
| 169 | + s[2].get("type") or "")), | |
| 170 | + s[2].get("reviews") or 0, s[0])) | |
| 171 | + else: | |
| 172 | + best_conf, best_sig, best = max(scored, key=lambda s: s[0]) | |
| 173 | + now = time.time() | |
| 174 | + if best is None or best_conf < SEUIL_CONFIANCE: | |
| 175 | + con.execute( | |
| 176 | + "UPDATE managers SET resolve_failed=1, resolved_at=?," | |
| 177 | + " match_confidence=?, match_signals=? WHERE id=?", | |
| 178 | + (now, round(best_conf, 3) if best else None, | |
| 179 | + json.dumps({"refus": "confiance insuffisante", | |
| 180 | + "candidats": len(cands), **best_sig}, | |
| 181 | + ensure_ascii=False), manager_id)) | |
| 182 | + con.commit() | |
| 183 | + return {"ok": False, "raison": "confiance insuffisante", | |
| 184 | + "confiance": round(best_conf, 3)} | |
| 185 | + con.execute( | |
| 186 | + "UPDATE managers SET gmaps_place_id=?, gmaps_data_id=?," | |
| 187 | + " gmaps_name=?, gmaps_address=?, gmaps_rating=?, gmaps_reviews=?," | |
| 188 | + " match_confidence=?, match_signals=?, resolved_at=?," | |
| 189 | + " resolve_failed=0, phone=COALESCE(phone, ?) WHERE id=?", | |
| 190 | + (best.get("place_id"), best.get("data_id"), best.get("title"), | |
| 191 | + best.get("address"), best.get("rating"), best.get("reviews"), | |
| 192 | + round(best_conf, 3), json.dumps(best_sig, ensure_ascii=False), | |
| 193 | + now, best.get("phone"), manager_id)) | |
| 194 | + con.commit() | |
| 195 | + return {"ok": True, "gmaps_name": best.get("title"), | |
| 196 | + "confiance": round(best_conf, 3)} | |
| 197 | + finally: | |
| 198 | + if own: | |
| 199 | + con.close() | |
| 200 | + | |
| 201 | + | |
| 202 | +# --- 3) synchronisation des avis (paginée, dédupliquée, en base) ------------- | |
| 203 | + | |
| 204 | +def _content_hash(author: str | None, rating, text: str | None) -> str: | |
| 205 | + raw = f"{author or ''}|{rating}|{(text or '')[:200]}" | |
| 206 | + return hashlib.sha1(raw.encode("utf-8")).hexdigest() | |
| 207 | + | |
| 208 | + | |
| 209 | +def sync_reviews(manager_id: int, con=None, max_pages: int = MAX_PAGES) -> dict: | |
| 210 | + """Rapatrie les avis Google (les plus récents d'abord) dans NOTRE base.""" | |
| 211 | + own = con is None | |
| 212 | + if own: | |
| 213 | + con = db.connect() | |
| 214 | + try: | |
| 215 | + mgr = con.execute("SELECT * FROM managers WHERE id=?", | |
| 216 | + (manager_id,)).fetchone() | |
| 217 | + if mgr is None or not mgr["gmaps_data_id"]: | |
| 218 | + return {"ok": False, "raison": "fiche Google non associée"} | |
| 219 | + known = {r["external_review_id"] for r in con.execute( | |
| 220 | + "SELECT external_review_id FROM manager_reviews WHERE manager_id=?", | |
| 221 | + (manager_id,))} | |
| 222 | + known_hash = {r["content_hash"] for r in con.execute( | |
| 223 | + "SELECT content_hash FROM manager_reviews WHERE manager_id=?" | |
| 224 | + " AND external_review_id IS NULL", (manager_id,))} | |
| 225 | + | |
| 226 | + token, new, pages = None, 0, 0 | |
| 227 | + while pages < max_pages: | |
| 228 | + data = serpapi_client.google_maps_reviews( | |
| 229 | + mgr["gmaps_data_id"], next_page_token=token) | |
| 230 | + reviews = data.get("reviews") or [] | |
| 231 | + pages += 1 | |
| 232 | + page_new = 0 | |
| 233 | + now = time.time() | |
| 234 | + for rv in reviews: | |
| 235 | + ext = rv.get("review_id") | |
| 236 | + text = rv.get("snippet") or rv.get("extracted_snippet", | |
| 237 | + {}).get("original") | |
| 238 | + user = rv.get("user") or {} | |
| 239 | + chash = _content_hash(user.get("name"), rv.get("rating"), text) | |
| 240 | + if (ext and ext in known) or (not ext and chash in known_hash): | |
| 241 | + continue | |
| 242 | + resp = rv.get("response") or {} | |
| 243 | + analysis = _analyze_review(rv.get("rating"), text) | |
| 244 | + con.execute( | |
| 245 | + "INSERT INTO manager_reviews (manager_id," | |
| 246 | + " external_review_id, source, rating, text, published_at," | |
| 247 | + " relative_date_raw, author_name, author_review_count," | |
| 248 | + " owner_response, owner_response_date, source_url," | |
| 249 | + " fetched_at, content_hash, analysis) VALUES" | |
| 250 | + " (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" | |
| 251 | + " ON CONFLICT(manager_id, external_review_id) DO NOTHING", | |
| 252 | + (manager_id, ext, "google_maps", rv.get("rating"), text, | |
| 253 | + rv.get("iso_date"), rv.get("date"), user.get("name"), | |
| 254 | + user.get("reviews"), resp.get("snippet"), | |
| 255 | + resp.get("iso_date") or resp.get("date"), rv.get("link"), | |
| 256 | + now, chash, json.dumps(analysis, ensure_ascii=False))) | |
| 257 | + if ext: | |
| 258 | + known.add(ext) | |
| 259 | + else: | |
| 260 | + known_hash.add(chash) | |
| 261 | + page_new += 1 | |
| 262 | + new += page_new | |
| 263 | + token = (data.get("serpapi_pagination") or {}).get( | |
| 264 | + "next_page_token") | |
| 265 | + # tri « plus récents d'abord » : une page entièrement connue | |
| 266 | + # signifie qu'on a rejoint l'historique déjà synchronisé | |
| 267 | + if not token or (reviews and page_new == 0): | |
| 268 | + break | |
| 269 | + stats = _aggregate(con, manager_id) | |
| 270 | + con.execute("UPDATE managers SET review_stats=?, last_synced_at=?" | |
| 271 | + " WHERE id=?", | |
| 272 | + (json.dumps(stats, ensure_ascii=False), time.time(), | |
| 273 | + manager_id)) | |
| 274 | + con.commit() | |
| 275 | + return {"ok": True, "nouveaux": new, "pages": pages, | |
| 276 | + "total": stats.get("n")} | |
| 277 | + finally: | |
| 278 | + if own: | |
| 279 | + con.close() | |
| 280 | + | |
| 281 | + | |
| 282 | +# --- 4) analyse : thèmes (lexique fr), sentiment, sévérité, agrégats --------- | |
| 283 | + | |
| 284 | +TOPICS = { | |
| 285 | + "entretien": ["entretien", "entreten", "maintenance", "neglige", "négligé", | |
| 286 | + "delabre", "délabré", "moisissure"], | |
| 287 | + "réparations": ["reparation", "réparation", "reparer", "réparer", "bris", | |
| 288 | + "brise", "brisé", "fuite", "degat", "dégât", "plomberie"], | |
| 289 | + "communication": ["communication", "repond", "répond", "reponse", | |
| 290 | + "réponse", "rappel", "courriel", "joindre", | |
| 291 | + "injoignable", "appel", "message"], | |
| 292 | + "personnel": ["personnel", "employe", "employé", "concierge", "equipe", | |
| 293 | + "équipe", "gerant", "gérant", "agent", "proprietaire", | |
| 294 | + "propriétaire"], | |
| 295 | + "propreté": ["propre", "proprete", "propreté", "sale", "salete", "saleté", | |
| 296 | + "malpropre"], | |
| 297 | + "chauffage": ["chauffage", "chauffe", "chauffé", "froid", "temperature", | |
| 298 | + "température"], | |
| 299 | + "eau chaude": ["eau chaude"], | |
| 300 | + "vermine": ["punaise", "coquerelle", "souris", "rat ", "rats", "insecte", | |
| 301 | + "vermine", "fourmi", "extermina"], | |
| 302 | + "bruit": ["bruit", "bruyant", "tapage", "insonoris"], | |
| 303 | + "sécurité": ["securite", "sécurité", "securitaire", "sécuritaire", "vol", | |
| 304 | + "intrusion", "serrure", "camera", "caméra"], | |
| 305 | + "ascenseur": ["ascenseur"], | |
| 306 | + "stationnement": ["stationnement", "parking", "deneigement", | |
| 307 | + "déneigement"], | |
| 308 | + "dépôt et remboursement": ["depot", "dépôt", "rembours", "caution"], | |
| 309 | + "augmentation de loyer": ["augmentation", "hausse"], | |
| 310 | + "bail et location": ["bail", "signature", "visite", "location"], | |
| 311 | + "service client": ["service", "clientele", "clientèle", "professionnel", | |
| 312 | + "courtois", "arrogant", "impoli", "respect"], | |
| 313 | +} | |
| 314 | +_GRAVES = {"vermine", "sécurité", "chauffage", "eau chaude"} | |
| 315 | + | |
| 316 | + | |
| 317 | +def _analyze_review(rating, text: str | None) -> dict: | |
| 318 | + t = (text or "").lower() | |
| 319 | + topics = sorted({name for name, kws in TOPICS.items() | |
| 320 | + if any(k in t for k in kws)}) | |
| 321 | + if rating is None: | |
| 322 | + sentiment = "inconnu" | |
| 323 | + elif rating <= 2: | |
| 324 | + sentiment = "négatif" | |
| 325 | + elif rating >= 4: | |
| 326 | + sentiment = "positif" | |
| 327 | + else: | |
| 328 | + sentiment = "neutre" | |
| 329 | + severity = "faible" | |
| 330 | + if sentiment == "négatif": | |
| 331 | + severity = "élevée" if any(x in _GRAVES for x in topics) else "moyenne" | |
| 332 | + if len(t) >= 80 and topics: | |
| 333 | + confidence = 0.7 | |
| 334 | + elif t: | |
| 335 | + confidence = 0.4 | |
| 336 | + else: | |
| 337 | + confidence = 0.2 | |
| 338 | + return {"topics": topics, "sentiment": sentiment, "severite": severity, | |
| 339 | + "confiance": confidence, "statut": "inferred", | |
| 340 | + "methode": "lexique de thèmes fr + sentiment dérivé de la note"} | |
| 341 | + | |
| 342 | + | |
| 343 | +def _aggregate(con, manager_id: int) -> dict: | |
| 344 | + rows = con.execute( | |
| 345 | + "SELECT rating, published_at, owner_response, analysis" | |
| 346 | + " FROM manager_reviews WHERE manager_id=?", (manager_id,)).fetchall() | |
| 347 | + rated = [r for r in rows if r["rating"] is not None] | |
| 348 | + out: dict = {"n": len(rows), "version": VERSION, "statut": "calculated", | |
| 349 | + "methode": ("statistiques calculées sur les avis Google " | |
| 350 | + "synchronisés en base (pas seulement la moyenne " | |
| 351 | + "affichée par Google) — l'échantillon peut être " | |
| 352 | + "plus petit que le total annoncé par Google, il " | |
| 353 | + "s'accumule à chaque synchronisation ; thèmes " | |
| 354 | + "par lexique — inférence, pas une lecture " | |
| 355 | + "humaine")} | |
| 356 | + if not rated: | |
| 357 | + return out | |
| 358 | + out["moyenne"] = round(sum(r["rating"] for r in rated) / len(rated), 2) | |
| 359 | + dist = {str(i): 0 for i in range(1, 6)} | |
| 360 | + for r in rated: | |
| 361 | + k = str(int(round(r["rating"]))) | |
| 362 | + if k in dist: | |
| 363 | + dist[k] += 1 | |
| 364 | + out["distribution"] = dist | |
| 365 | + out["pct_negatif"] = round(100 * (dist["1"] + dist["2"]) / len(rated)) | |
| 366 | + out["pct_positif"] = round(100 * (dist["4"] + dist["5"]) / len(rated)) | |
| 367 | + out["avec_reponse_proprietaire"] = sum( | |
| 368 | + 1 for r in rows if r["owner_response"]) | |
| 369 | + | |
| 370 | + cutoff = datetime.now(timezone.utc).timestamp() - 365 * 86400 | |
| 371 | + rec, old = [], [] | |
| 372 | + for r in rated: | |
| 373 | + ts = _iso_ts(r["published_at"]) | |
| 374 | + (rec if ts and ts >= cutoff else old).append(r["rating"]) | |
| 375 | + if rec: | |
| 376 | + out["moyenne_12m"] = round(sum(rec) / len(rec), 2) | |
| 377 | + out["n_12m"] = len(rec) | |
| 378 | + if len(rec) >= 5 and len(old) >= 5: | |
| 379 | + delta = out["moyenne_12m"] - sum(old) / len(old) | |
| 380 | + out["tendance"] = ("en amélioration" if delta >= 0.3 else | |
| 381 | + "en dégradation" if delta <= -0.3 else "stable") | |
| 382 | + out["tendance_delta"] = round(delta, 2) | |
| 383 | + | |
| 384 | + themes: dict[str, dict] = {} | |
| 385 | + for r in rows: | |
| 386 | + try: | |
| 387 | + a = json.loads(r["analysis"] or "{}") | |
| 388 | + except (ValueError, TypeError): | |
| 389 | + continue | |
| 390 | + for tp in a.get("topics") or []: | |
| 391 | + d = themes.setdefault(tp, {"mentions": 0, "negatif": 0, | |
| 392 | + "positif": 0}) | |
| 393 | + d["mentions"] += 1 | |
| 394 | + if a.get("sentiment") == "négatif": | |
| 395 | + d["negatif"] += 1 | |
| 396 | + elif a.get("sentiment") == "positif": | |
| 397 | + d["positif"] += 1 | |
| 398 | + out["themes"] = dict(sorted(themes.items(), | |
| 399 | + key=lambda kv: -kv[1]["mentions"])) | |
| 400 | + out["plaintes_frequentes"] = [ | |
| 401 | + k for k, v in sorted(themes.items(), key=lambda kv: -kv[1]["negatif"]) | |
| 402 | + if v["negatif"] >= 2][:5] | |
| 403 | + return out | |
| 404 | + | |
| 405 | + | |
| 406 | +def _iso_ts(iso: str | None) -> float | None: | |
| 407 | + if not iso: | |
| 408 | + return None | |
| 409 | + try: | |
| 410 | + return datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp() | |
| 411 | + except ValueError: | |
| 412 | + return None | |
| 413 | + | |
| 414 | + | |
| 415 | +# --- fiche API + boucle de fond ---------------------------------------------- | |
| 416 | + | |
| 417 | +def fiche(source_id: str, con=None) -> dict | None: | |
| 418 | + """Fiche gestionnaire pour l'API — tout vient de NOTRE base (0 SerpApi).""" | |
| 419 | + own = con is None | |
| 420 | + if own: | |
| 421 | + con = db.connect() | |
| 422 | + try: | |
| 423 | + mgr = con.execute("SELECT * FROM managers WHERE source_id=?", | |
| 424 | + (source_id,)).fetchone() | |
| 425 | + if mgr is None: | |
| 426 | + return None | |
| 427 | + m = dict(mgr) | |
| 428 | + n_active = con.execute( | |
| 429 | + "SELECT COUNT(*) n FROM listings WHERE source=? AND active=1" | |
| 430 | + " AND published=1 AND dup_of IS NULL", (source_id,)).fetchone()["n"] | |
| 431 | + out = { | |
| 432 | + "source_id": m["source_id"], "nom": m["name"], | |
| 433 | + "site_web": m["website"], "telephone": m["phone"], | |
| 434 | + "annonces_actives": n_active, "version": VERSION, | |
| 435 | + } | |
| 436 | + if m["gmaps_place_id"] and not m["resolve_failed"]: | |
| 437 | + out["google_maps"] = { | |
| 438 | + "nom": m["gmaps_name"], "adresse": m["gmaps_address"], | |
| 439 | + "note": m["gmaps_rating"], "nombre_avis": m["gmaps_reviews"], | |
| 440 | + "place_id": m["gmaps_place_id"], | |
| 441 | + "confiance_association": m["match_confidence"], | |
| 442 | + "signaux": json.loads(m["match_signals"] or "{}"), | |
| 443 | + "statut": "inferred", | |
| 444 | + "methode": ("fiche associée par similarité de nom, domaine " | |
| 445 | + "web et ville — association refusée sous " | |
| 446 | + f"{SEUIL_CONFIANCE:.2f} de confiance"), | |
| 447 | + } | |
| 448 | + try: | |
| 449 | + out["avis"] = json.loads(m["review_stats"] or "null") | |
| 450 | + except (ValueError, TypeError): | |
| 451 | + out["avis"] = None | |
| 452 | + out["avis_recents"] = [ | |
| 453 | + {"note": r["rating"], "texte": r["text"], | |
| 454 | + "date": r["published_at"] or r["relative_date_raw"], | |
| 455 | + "auteur": r["author_name"], | |
| 456 | + "reponse_proprietaire": bool(r["owner_response"]), | |
| 457 | + "analyse": json.loads(r["analysis"] or "{}"), | |
| 458 | + "statut": "observed"} | |
| 459 | + for r in con.execute( | |
| 460 | + "SELECT rating, text, published_at, relative_date_raw," | |
| 461 | + " author_name, owner_response, analysis" | |
| 462 | + " FROM manager_reviews WHERE manager_id=?" | |
| 463 | + " ORDER BY published_at DESC, fetched_at DESC LIMIT 10", | |
| 464 | + (m["id"],))] | |
| 465 | + out["derniere_synchro"] = m["last_synced_at"] | |
| 466 | + elif m["resolved_at"]: | |
| 467 | + out["google_maps"] = { | |
| 468 | + "statut": "non_associe", | |
| 469 | + "note_methode": ("aucune fiche Google Maps n'a pu être " | |
| 470 | + "associée avec une confiance suffisante — " | |
| 471 | + "Lou-Ka préfère ne rien afficher plutôt que " | |
| 472 | + "d'attribuer les avis d'une autre entreprise"), | |
| 473 | + } | |
| 474 | + return out | |
| 475 | + finally: | |
| 476 | + if own: | |
| 477 | + con.close() | |
| 478 | + | |
| 479 | + | |
| 480 | +def precompute(budget_s: float = 120.0, con=None) -> dict: | |
| 481 | + """Boucle de fond : amorce, résout et synchronise dans un budget temps.""" | |
| 482 | + if not serpapi_client.available(): | |
| 483 | + return {"skipped": "SERPAPI_API_KEY absente"} | |
| 484 | + own = con is None | |
| 485 | + if own: | |
| 486 | + con = db.connect() | |
| 487 | + t0 = time.time() | |
| 488 | + resolved = synced = errors = 0 | |
| 489 | + try: | |
| 490 | + seed(con) | |
| 491 | + now = time.time() | |
| 492 | + # 1) résolutions manquantes (ou échecs anciens à retenter) | |
| 493 | + for r in con.execute( | |
| 494 | + "SELECT id FROM managers WHERE resolved_at IS NULL" | |
| 495 | + " OR (resolve_failed=1 AND resolved_at < ?)" | |
| 496 | + " ORDER BY resolved_at IS NOT NULL, id", | |
| 497 | + (now - RESOLVE_RETRY,)).fetchall(): | |
| 498 | + if time.time() - t0 > budget_s: | |
| 499 | + break | |
| 500 | + try: | |
| 501 | + if resolve(r["id"], con).get("ok"): | |
| 502 | + resolved += 1 | |
| 503 | + except serpapi_client.SerpApiError: | |
| 504 | + errors += 1 | |
| 505 | + break # panne API : ne pas insister ce tour-ci | |
| 506 | + # 2) avis périmés (les jamais-synchronisés d'abord) | |
| 507 | + for r in con.execute( | |
| 508 | + "SELECT id FROM managers WHERE gmaps_data_id IS NOT NULL" | |
| 509 | + " AND resolve_failed=0 AND (last_synced_at IS NULL" | |
| 510 | + " OR last_synced_at < ?)" | |
| 511 | + " ORDER BY last_synced_at IS NOT NULL, last_synced_at", | |
| 512 | + (now - SYNC_TTL,)).fetchall(): | |
| 513 | + if time.time() - t0 > budget_s: | |
| 514 | + break | |
| 515 | + try: | |
| 516 | + if sync_reviews(r["id"], con).get("ok"): | |
| 517 | + synced += 1 | |
| 518 | + except serpapi_client.SerpApiError: | |
| 519 | + errors += 1 | |
| 520 | + break | |
| 521 | + return {"resolved": resolved, "synced": synced, "errors": errors, | |
| 522 | + "elapsed_s": round(time.time() - t0, 1)} | |
| 523 | + finally: | |
| 524 | + if own: | |
| 525 | + con.close() | |
| 526 | + | |
| 527 | + | |
| 528 | +if __name__ == "__main__": | |
| 529 | + print(precompute()) | |
added
louka/recycled.py
+185 −0
@@ -0,0 +1,185 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# recycled.py : détection des annonces RECYCLÉES — le même logement republié | |
| 5 | +# (souvent plus cher) après un passage antérieur sur le marché. | |
| 6 | +# | |
| 7 | +# Méthode : les candidates sont les annonces INACTIVES du même immeuble | |
| 8 | +# (building_key — même clé d'adresse que la déduplication). Chaque paire reçoit | |
| 9 | +# un score de similarité 0–100 fondé sur des signaux indépendants : | |
| 10 | +# numéro d'unité identique, type d'unité, chambres, superficie (±3 %), | |
| 11 | +# photos identiques (hash SHA1 du contrôle d'images), texte de description | |
| 12 | +# (similarité de Jaccard sur les mots). | |
| 13 | +# Seules les correspondances ≥ SEUIL_AFFICHAGE sont retournées, avec les | |
| 14 | +# signaux qui les fondent (inférence explicable, jamais de fusion automatique). | |
| 15 | +# Cache : table recycled_cache (TTL 7 jours — recalcul léger, borné à | |
| 16 | +# l'immeuble). | |
| 17 | +# ----------------------------------------------------------------------------- | |
| 18 | +from __future__ import annotations | |
| 19 | + | |
| 20 | +import json | |
| 21 | +import re | |
| 22 | +import time | |
| 23 | + | |
| 24 | +from . import db | |
| 25 | + | |
| 26 | +TTL = 7 * 86400 | |
| 27 | +SEUIL_AFFICHAGE = 70 # score minimal pour présenter une correspondance | |
| 28 | +MAX_CANDIDATES = 200 # garde-fou (tours à très fort volume) | |
| 29 | + | |
| 30 | +_WORD = re.compile(r"[a-zà-ÿ0-9]{3,}") | |
| 31 | + | |
| 32 | + | |
| 33 | +def _tokens(text: str | None) -> set[str]: | |
| 34 | + return set(_WORD.findall((text or "").lower())) | |
| 35 | + | |
| 36 | + | |
| 37 | +def _jaccard(a: set[str], b: set[str]) -> float: | |
| 38 | + if not a or not b: | |
| 39 | + return 0.0 | |
| 40 | + return len(a & b) / len(a | b) | |
| 41 | + | |
| 42 | + | |
| 43 | +def _sha1_set(con, images_json: str | None) -> set[str]: | |
| 44 | + try: | |
| 45 | + urls = json.loads(images_json or "[]")[:20] | |
| 46 | + except (ValueError, TypeError): | |
| 47 | + return set() | |
| 48 | + if not urls: | |
| 49 | + return set() | |
| 50 | + marks = ",".join("?" * len(urls)) | |
| 51 | + return {r["sha1"] for r in con.execute( | |
| 52 | + f"SELECT sha1 FROM image_checks WHERE url IN ({marks})" | |
| 53 | + " AND sha1 IS NOT NULL", urls)} | |
| 54 | + | |
| 55 | + | |
| 56 | +def _unit_of(address: str | None, city: str | None) -> str | None: | |
| 57 | + from .dedup import _parse_address | |
| 58 | + return _parse_address(address or "", city or "")[1] | |
| 59 | + | |
| 60 | + | |
| 61 | +def _score(con, cur: dict, cand: dict) -> tuple[int, list[str]]: | |
| 62 | + """Score 0–100 + signaux lisibles justifiant la correspondance.""" | |
| 63 | + score = 0 | |
| 64 | + signals: list[str] = [] | |
| 65 | + if cur["unit"] and cand["unit"]: | |
| 66 | + if cur["unit"] == cand["unit"]: | |
| 67 | + score += 35 | |
| 68 | + signals.append(f"même numéro d'unité ({cur['unit']})") | |
| 69 | + else: | |
| 70 | + return 0, [] # unités connues et différentes : pas le même | |
| 71 | + if cur["unit_type"] and cand["unit_type"]: | |
| 72 | + if cur["unit_type"] == cand["unit_type"]: | |
| 73 | + score += 10 | |
| 74 | + signals.append(f"même type ({cur['unit_type']})") | |
| 75 | + else: | |
| 76 | + return 0, [] | |
| 77 | + if cur["bedrooms"] is not None and cand["bedrooms"] is not None: | |
| 78 | + if cur["bedrooms"] == cand["bedrooms"]: | |
| 79 | + score += 8 | |
| 80 | + signals.append("même nombre de chambres") | |
| 81 | + else: | |
| 82 | + return 0, [] | |
| 83 | + if cur["area"] and cand["area"]: | |
| 84 | + if abs(cur["area"] - cand["area"]) <= 0.03 * max(cur["area"], cand["area"]): | |
| 85 | + score += 15 | |
| 86 | + signals.append(f"même superficie (≈{round(cand['area'])} pi²)") | |
| 87 | + else: | |
| 88 | + score -= 10 | |
| 89 | + # photos identiques (hash du contenu, pas l'URL) | |
| 90 | + sh_cur = _sha1_set(con, cur["images"]) | |
| 91 | + sh_cand = _sha1_set(con, cand["images"]) | |
| 92 | + if sh_cur and sh_cand: | |
| 93 | + overlap = len(sh_cur & sh_cand) / min(len(sh_cur), len(sh_cand)) | |
| 94 | + if overlap >= 0.5: | |
| 95 | + score += 30 | |
| 96 | + signals.append(f"{len(sh_cur & sh_cand)} photo(s) identique(s)") | |
| 97 | + elif overlap > 0: | |
| 98 | + score += 12 | |
| 99 | + signals.append("photos partiellement identiques") | |
| 100 | + # texte de description | |
| 101 | + jac = _jaccard(_tokens(cur["description"]), _tokens(cand["description"])) | |
| 102 | + if jac >= 0.7: | |
| 103 | + score += 20 | |
| 104 | + signals.append("description quasi identique") | |
| 105 | + elif jac >= 0.45: | |
| 106 | + score += 10 | |
| 107 | + signals.append("description très similaire") | |
| 108 | + # même source + même identifiant externe = republication certaine | |
| 109 | + if cur["source"] == cand["source"]: | |
| 110 | + score += 5 | |
| 111 | + return min(100, score), signals | |
| 112 | + | |
| 113 | + | |
| 114 | +def lookup(uid: str, con=None) -> dict: | |
| 115 | + """Correspondances probables (annonces antérieures du même logement).""" | |
| 116 | + own = con is None | |
| 117 | + if own: | |
| 118 | + con = db.connect() | |
| 119 | + try: | |
| 120 | + cached = con.execute( | |
| 121 | + "SELECT matches, computed_at FROM recycled_cache WHERE uid=?", | |
| 122 | + (uid,)).fetchone() | |
| 123 | + if cached and time.time() - cached["computed_at"] < TTL: | |
| 124 | + return json.loads(cached["matches"]) | |
| 125 | + | |
| 126 | + cur = con.execute( | |
| 127 | + "SELECT uid, source, address, city, unit_type, bedrooms, price," | |
| 128 | + " area_sqft AS area, description, images, first_seen, building_key," | |
| 129 | + " dup_of FROM listings WHERE uid=?", (uid,)).fetchone() | |
| 130 | + out: dict = {"matches": [], "statut": "inferred", | |
| 131 | + "methode": ("similarité multi-signaux (unité, type, " | |
| 132 | + "chambres, superficie, photos par hash, " | |
| 133 | + "texte) parmi les annonces antérieures du " | |
| 134 | + "même immeuble")} | |
| 135 | + if cur is None or not cur["building_key"]: | |
| 136 | + _store(con, uid, out) | |
| 137 | + return out | |
| 138 | + cur_d = dict(cur) | |
| 139 | + cur_d["unit"] = _unit_of(cur["address"], cur["city"]) | |
| 140 | + | |
| 141 | + # exclure le groupe de doublons inter-sources courant (même annonce) | |
| 142 | + dup_group = {uid, cur["dup_of"] or ""} | |
| 143 | + for r in con.execute("SELECT uid FROM listings WHERE dup_of=?", (uid,)): | |
| 144 | + dup_group.add(r["uid"]) | |
| 145 | + | |
| 146 | + cands = con.execute( | |
| 147 | + "SELECT uid, source, address, city, unit_type, bedrooms, price," | |
| 148 | + " area_sqft AS area, description, images, first_seen, last_seen" | |
| 149 | + " FROM listings WHERE building_key=? AND active=0 AND uid<>?" | |
| 150 | + " ORDER BY last_seen DESC LIMIT ?", | |
| 151 | + (cur["building_key"], uid, MAX_CANDIDATES)).fetchall() | |
| 152 | + matches = [] | |
| 153 | + for c in cands: | |
| 154 | + if c["uid"] in dup_group: | |
| 155 | + continue | |
| 156 | + # une annonce antérieure = terminée avant l'apparition de l'actuelle | |
| 157 | + if (c["last_seen"] or 0) > (cur["first_seen"] or 0) + 7 * 86400: | |
| 158 | + continue | |
| 159 | + cd = dict(c) | |
| 160 | + cd["unit"] = _unit_of(c["address"], c["city"]) | |
| 161 | + score, signals = _score(con, cur_d, cd) | |
| 162 | + if score >= SEUIL_AFFICHAGE: | |
| 163 | + matches.append({ | |
| 164 | + "uid": c["uid"], "source": c["source"], | |
| 165 | + "prix": c["price"], "unit_type": c["unit_type"], | |
| 166 | + "derniere_observation": c["last_seen"], | |
| 167 | + "premiere_observation": c["first_seen"], | |
| 168 | + "confiance": score, "signaux": signals, | |
| 169 | + }) | |
| 170 | + matches.sort(key=lambda m: -m["confiance"]) | |
| 171 | + out["matches"] = matches[:5] | |
| 172 | + _store(con, uid, out) | |
| 173 | + return out | |
| 174 | + finally: | |
| 175 | + if own: | |
| 176 | + con.close() | |
| 177 | + | |
| 178 | + | |
| 179 | +def _store(con, uid: str, out: dict) -> None: | |
| 180 | + con.execute( | |
| 181 | + "INSERT INTO recycled_cache (uid, matches, computed_at) VALUES (?,?,?)" | |
| 182 | + " ON CONFLICT(uid) DO UPDATE SET matches=excluded.matches," | |
| 183 | + " computed_at=excluded.computed_at", | |
| 184 | + (uid, json.dumps(out, ensure_ascii=False), time.time())) | |
| 185 | + con.commit() | |
added
louka/serpapi_client.py
+86 −0
@@ -0,0 +1,86 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# 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_KEY | |
| 8 | +# (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 les | |
| 14 | +# erreurs transitoires (réseau, 429, 5xx). Les erreurs définitives (4xx) | |
| 15 | +# remontent immédiatement. | |
| 16 | +# ----------------------------------------------------------------------------- | |
| 17 | +from __future__ import annotations | |
| 18 | + | |
| 19 | +import os | |
| 20 | +import time | |
| 21 | + | |
| 22 | +import requests | |
| 23 | + | |
| 24 | +BASE = "https://serpapi.com/search.json" | |
| 25 | +TIMEOUT = 30 | |
| 26 | +RETRIES = 3 | |
| 27 | +BACKOFF = 2.0 # s — doublé à chaque tentative | |
| 28 | + | |
| 29 | + | |
| 30 | +class SerpApiError(RuntimeError): | |
| 31 | + pass | |
| 32 | + | |
| 33 | + | |
| 34 | +def api_key() -> str | None: | |
| 35 | + return os.environ.get("SERPAPI_API_KEY") or None | |
| 36 | + | |
| 37 | + | |
| 38 | +def available() -> bool: | |
| 39 | + return api_key() is not None | |
| 40 | + | |
| 41 | + | |
| 42 | +def 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"] = key | |
| 49 | + 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 | + continue | |
| 59 | + if r.status_code == 429 or r.status_code >= 500: | |
| 60 | + last = f"HTTP {r.status_code}" | |
| 61 | + continue | |
| 62 | + 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 panne | |
| 67 | + if "hasn't returned any results" in str(data["error"]): | |
| 68 | + return data | |
| 69 | + raise SerpApiError(f"SerpApi: {data['error']}") | |
| 70 | + return data | |
| 71 | + raise SerpApiError(f"SerpApi injoignable après {RETRIES} tentatives ({last})") | |
| 72 | + | |
| 73 | + | |
| 74 | +def google_maps(query: str) -> dict: | |
| 75 | + return search({"engine": "google_maps", "q": query, "hl": "fr", | |
| 76 | + "gl": "ca", "google_domain": "google.ca", "type": "search"}) | |
| 77 | + | |
| 78 | + | |
| 79 | +def 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_token | |
| 84 | + p["next_page_token"] = next_page_token | |
| 85 | + p["num"] = 20 | |
| 86 | + return search(p) | |
modified
louka/web.py
+59 −0
@@ -610,6 +610,57 @@ def tal_history(address: str, city: str | None = None): | ||
| 610 | 610 | return tal.lookup(address, city) |
| 611 | 611 | |
| 612 | 612 | |
| 613 | +@app.get("/api/listings/{uid}/historique") | |
| 614 | +def listing_history(uid: str): | |
| 615 | + """Historique Lou-Ka : timeline des observations réelles (prix, texte, | |
| 616 | + photos, disponibilité, retraits/retours) — voir louka/history.py.""" | |
| 617 | + from . import history | |
| 618 | + out = history.timeline(uid) | |
| 619 | + if out is None: | |
| 620 | + raise HTTPException(404, "Annonce introuvable") | |
| 621 | + return out | |
| 622 | + | |
| 623 | + | |
| 624 | +@app.get("/api/listings/{uid}/cout-reel") | |
| 625 | +def listing_cout_reel(uid: str): | |
| 626 | + """Coût réel mensuel : loyer + frais non inclus ligne par ligne, chaque | |
| 627 | + poste étiqueté included/observed/estimated/unknown — louka/coutreel.py.""" | |
| 628 | + from . import coutreel | |
| 629 | + out = coutreel.compute(uid) | |
| 630 | + if out is None: | |
| 631 | + raise HTTPException(404, "Annonce introuvable") | |
| 632 | + return out | |
| 633 | + | |
| 634 | + | |
| 635 | +@app.get("/api/listings/{uid}/recyclees") | |
| 636 | +def listing_recycled(uid: str): | |
| 637 | + """Annonces antérieures probables du même logement (score multi-signaux, | |
| 638 | + inférence explicable) — louka/recycled.py.""" | |
| 639 | + from . import recycled | |
| 640 | + return recycled.lookup(uid) | |
| 641 | + | |
| 642 | + | |
| 643 | +@app.get("/api/immeuble") | |
| 644 | +def immeuble_fiche(bkey: str): | |
| 645 | + """Passeport de l'immeuble (stats précalculées par building.rollup).""" | |
| 646 | + from . import building | |
| 647 | + out = building.fiche(bkey) | |
| 648 | + if out is None: | |
| 649 | + raise HTTPException(404, "Immeuble inconnu") | |
| 650 | + return out | |
| 651 | + | |
| 652 | + | |
| 653 | +@app.get("/api/managers/{source_id}") | |
| 654 | +def manager_fiche(source_id: str): | |
| 655 | + """Fiche du gestionnaire (source directe) + avis Google synchronisés en | |
| 656 | + base — aucun appel SerpApi au chargement de page (louka/managers.py).""" | |
| 657 | + from . import managers | |
| 658 | + out = managers.fiche(source_id) | |
| 659 | + if out is None: | |
| 660 | + raise HTTPException(404, "Gestionnaire inconnu") | |
| 661 | + return out | |
| 662 | + | |
| 663 | + | |
| 613 | 664 | @app.get("/api/listings/{uid}") |
| 614 | 665 | def get_listing(uid: str): |
| 615 | 666 | con = db.connect() |
@@ -650,6 +701,14 @@ def get_listing(uid: str): | ||
| 650 | 701 | d["quartier"] = quartier.fiche_quartier( |
| 651 | 702 | d.get("lat"), d.get("lng"), d.get("city") or "", |
| 652 | 703 | dauid if dauid and dauid != "hors-zone" else None) |
| 704 | + # passeport de l'immeuble (stats précalculées) + score hiver | |
| 705 | + if d.get("building_key"): | |
| 706 | + from . import building | |
| 707 | + d["immeuble"] = building.fiche(d["building_key"], con) | |
| 708 | + else: | |
| 709 | + d["immeuble"] = None | |
| 710 | + from . import hiver | |
| 711 | + d["hiver"] = hiver.score(d.get("lat"), d.get("lng"), con) | |
| 653 | 712 | # description structurée + historique de prix |
| 654 | 713 | d["digest"] = json.loads(d["digest"]) if d.get("digest") else None |
| 655 | 714 | d["price_history"] = [dict(r) for r in con.execute( |
modified
run.py
+12 −0
@@ -15,6 +15,8 @@ | ||
| 15 | 15 | python run.py poi [n] # commodités de proximité par immeuble (max n requêtes) |
| 16 | 16 | python run.py env [n] # environnement OSM par tuile (socle des KA Scores) |
| 17 | 17 | python run.py kascores [all] # calcule les KA Scores du parc (all = tout recalculer) |
| 18 | + python run.py buildings # passeport des immeubles (building_key + stats) | |
| 19 | + python run.py managers [budget_s] # gestionnaires : fiche Google Maps + avis (SerpApi) | |
| 18 | 20 | """ |
| 19 | 21 | from __future__ import annotations |
| 20 | 22 | |
@@ -116,6 +118,16 @@ def main() -> None: | ||
| 116 | 118 | n = int(sys.argv[2]) if len(sys.argv) > 2 else 60 |
| 117 | 119 | b = int(sys.argv[3]) if len(sys.argv) > 3 else 600 |
| 118 | 120 | tal.precompute(limit=n, budget=b) |
| 121 | + elif cmd == "buildings": | |
| 122 | + # (re)calcule le passeport de tous les immeubles (building_key + stats) | |
| 123 | + from louka import building | |
| 124 | + print(building.rollup()) | |
| 125 | + elif cmd == "managers": | |
| 126 | + # gestionnaires : amorçage + résolution Google Maps + avis (SerpApi) | |
| 127 | + # « python run.py managers [budget_s] » | |
| 128 | + from louka import managers | |
| 129 | + b = float(sys.argv[2]) if len(sys.argv) > 2 else 300 | |
| 130 | + print(managers.precompute(budget_s=b)) | |
| 119 | 131 | elif cmd == "record": |
| 120 | 132 | from louka import fixtures |
| 121 | 133 | from louka.connectors import CONNECTORS |
| 122 | 134 | |