SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%

[ka2] snapshot pré-mission immobilia

Simon-Pierre Boucher committed 1 mo ago (Aug 24, 2026) parent 6ff906e

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