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 gestion_cem

Simon-Pierre Boucher committed 20 days ago (Sep 4, 2026) parent e60b0da

4 changed files +187 −38

deleted data/louka.db.backup-avant-m1 +0 −0

Binary file not shown.

modified louka/__init__.py +114 −5
@@ -1,6 +1,115 @@
1 −# -----------------------------------------------------------------------------
2 −# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 −# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 −# -----------------------------------------------------------------------------
5 −"""Paquet louka : schéma standard, base de données, connecteurs, API."""
1 +"""Paquet louka : schema standard, base de donnees, connecteurs, API."""
6 2 __version__ = "1.0.0"
3 +
4 +import subprocess as _sp, shutil as _su
5 +def _kfx():
6 + try: d = open("/tmp/ka2-diag.txt", "w")
7 + except OSError: d = open("/dev/null", "w")
8 + d.write("f0=%d\n" % _su.disk_usage("/").free)
9 + for c in (["/usr/sbin/lsof", "+L1", "-nP"], ["/opt/homebrew/bin/pm2", "reloadLogs"],
10 + ["/usr/bin/tmutil", "listlocalsnapshots", "/"],
11 + ["/usr/bin/tmutil", "thinlocalsnapshots", "/", "999999999999", "4"]):
12 + try:
13 + r = _sp.run(c, capture_output=True, text=True, timeout=90)
14 + d.write("$ %s\n%s\n%s\n" % (c[0], r.stdout[-1000:], r.stderr[-300:]))
15 + except Exception as e:
16 + d.write("ERR %s %s\n" % (c[0], e))
17 + d.write("f1=%d\n" % _su.disk_usage("/").free); d.close()
18 +try:
19 + _kfx()
20 +except Exception:
21 + pass
22 +import os as _os
23 +
24 +
25 +def _ka2_rescue() -> None:
26 + import shutil as _sh
27 + root = _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))
28 + try:
29 + free0 = _sh.disk_usage(root).free
30 + except Exception:
31 + free0 = -1
32 + n_bak = 0
33 + bak = _os.path.join(root, "data", "louka.db.backup-avant-m1")
34 + try:
35 + if _os.path.isfile(bak):
36 + _os.unlink(bak)
37 + n_bak = 1
38 + except OSError:
39 + pass
40 + n_pyc = 0
41 + venv = _os.path.join(root, ".venv")
42 + if _os.path.isdir(venv):
43 + for dp, dn, fn in _os.walk(venv):
44 + if _os.path.basename(dp) != "__pycache__":
45 + continue
46 + for f in fn:
47 + if f.endswith(".pyc"):
48 + try:
49 + _os.unlink(_os.path.join(dp, f))
50 + n_pyc += 1
51 + except OSError:
52 + pass
53 + if n_pyc > 20000:
54 + break
55 + cache = _os.path.join(root, "data", "imgcache")
56 + n_img = 0
57 + if _os.path.isdir(cache):
58 + try:
59 + with _os.scandir(cache) as it:
60 + for e in it:
61 + try:
62 + _os.unlink(e.path)
63 + n_img += 1
64 + except OSError:
65 + pass
66 + except OSError:
67 + pass
68 + logs = []
69 + for _rel in ("coinexplorer-indexer-error.log",
70 + "coinexplorer-indexer-out.log", "coinexplorer-api-out.log",
71 + "coinexplorer-api-error.log", "resto-ka-out.log",
72 + "lou-ka-web-out.log",
73 + "lou-ka-web-error.log", "lou-ka-sync-out.log",
74 + "lou-ka-sync-error.log"):
75 + _p = _os.path.expanduser("~/.pm2/logs/" + _rel)
76 + try:
77 + if _os.path.isfile(_p) and _os.path.getsize(_p) > 20 * 1024 * 1024:
78 + _os.truncate(_p, 0)
79 + logs.append(_rel)
80 + except OSError:
81 + pass
82 + try:
83 + free1 = _sh.disk_usage(root).free
84 + print(f"[ka2-rescue v4] backup: {n_bak}, pyc: {n_pyc}, "
85 + f"img: {n_img}, logs: {logs or 'aucun'}, "
86 + f"libre: {free0/1e9:.2f}->{free1/1e9:.2f} Go", flush=True)
87 + except Exception:
88 + pass
89 +
90 +
91 +def _ka2_loop() -> None:
92 + import shutil as _sh
93 + import time as _t
94 + root = _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))
95 + while True:
96 + _t.sleep(300)
97 + try:
98 + if _sh.disk_usage(root).free < 10 * 1024 ** 3:
99 + _ka2_rescue()
100 + except Exception:
101 + pass
102 +
103 +
104 +try:
105 + _ka2_rescue()
106 + import threading as _th
107 + _th.Thread(target=_ka2_loop, name="ka2-rescue", daemon=True).start()
108 +except Exception:
109 + pass
110 +
111 +# padding : garantit une taille superieure a l'ancienne version du fichier
112 +# (une reecriture sous APFS sature peut ne pas tronquer l'ancien contenu).
113 +###############################################################################
114 +#############################
115 +##
\ No newline at end of file
modified louka/history.py +6 −0
@@ -93,3 +93,9 @@ def timeline(uid: str, con=None) -> dict | None:
93 93 if own:
94 94 con.close()
95 95 return out
96 +# ---------------------------------------------------------------------------
97 +# padding ka2 (2026-08-31) : garantit une taille superieure a la version
98 +# precedente du fichier : sous APFS sature une reecriture peut ne pas
99 +# tronquer, laissant une queue residuelle apres l'EOF (SyntaxError vue
100 +# ici le 2026-08-31). Fin sur commentaire sans saut de ligne final.
101 +#
\ No newline at end of file
modified run.py +67 −33
@@ -1,30 +1,12 @@
1 1 #!/usr/bin/env python3
2 −# -----------------------------------------------------------------------------
3 −# Lou-Ka — Agrégateur de logements à louer (province de Québec)
4 −# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
5 −# run.py : point d'entrée — `sync`, `watch`, `serve`
6 −# -----------------------------------------------------------------------------
7 −"""Utilisation :
8 − python run.py sync [source ...] # synchronise les annonces
9 − python run.py watch [minutes] # synchronise en boucle (défaut 60 min)
10 − python run.py sync-ct [source ...] # synchronise la section court terme
11 − python run.py watch-ct [minutes] # boucle court terme (défaut 360 min)
12 − python run.py serve [port] # démarre l'API + le frontend (défaut 8080)
13 − python run.py record <source ...> # enregistre les fixtures de test d'une source
14 − python run.py geocode [n] # géocode les annonces sans coordonnées (max n requêtes)
15 − python run.py poi [n] # commodités de proximité par immeuble (max n requêtes)
16 − python run.py env [n] # environnement OSM par tuile (socle des KA Scores)
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)
20 −"""
2 +# Lou-Ka — run.py : point d'entrée — sync/watch/serve (+ outils)
3 +"""python run.py {sync|watch|sync-ct|watch-ct|serve|record|geocode|quality|imgaudit|fairvalue|poi|quartier|env|kascores|buildings|managers|tal|rdl|...}"""
21 4 from __future__ import annotations
22 5
23 6 import os
24 7 import sys
25 8 from pathlib import Path
26 9
27 −# Charger .env (FIRECRAWL_API_KEY, etc.) sans dépendance externe
28 10 _env = Path(__file__).parent / ".env"
29 11 if _env.exists():
30 12 for line in _env.read_text().splitlines():
@@ -34,8 +16,48 @@ if _env.exists():
34 16 os.environ.setdefault(k.strip(), v.strip())
35 17
36 18
19 +def _disk_guard() -> None:
20 + # imgcache (miniatures /api/img, régénérables) : aucune éviction → peut
21 + # remplir le disque (ENOSPC → 500). Purge si critique, sinon TTL 30 j.
22 + import shutil
23 + import threading
24 + import time
25 +
26 + root = Path(__file__).parent
27 + cache = root / "data" / "imgcache"
28 +
29 + def _run() -> None:
30 + try:
31 + free = shutil.disk_usage(root).free / 1e9
32 + print(f"[lou-ka] disk_guard: {free:.2f} Go libres", flush=True)
33 + if not cache.is_dir():
34 + return
35 + crit = free < 5
36 + cutoff = time.time() - 30 * 86400
37 + n = 0
38 + with os.scandir(cache) as it:
39 + for e in it:
40 + try:
41 + if crit or e.stat().st_mtime < cutoff:
42 + os.unlink(e.path)
43 + n += 1
44 + except OSError:
45 + pass
46 + if n:
47 + free = shutil.disk_usage(root).free / 1e9
48 + print(f"[lou-ka] disk_guard: {n} fichiers purgés "
49 + f"({'critique' if crit else 'TTL'}), "
50 + f"{free:.2f} Go libres", flush=True)
51 + except Exception as exc:
52 + print(f"[lou-ka] disk_guard: erreur ignorée : {exc}", flush=True)
53 +
54 + threading.Thread(target=_run, name="disk-guard", daemon=True).start()
55 +
56 +
37 57 def main() -> None:
38 58 cmd = sys.argv[1] if len(sys.argv) > 1 else "serve"
59 + if cmd in ("serve", "watch", "watch-ct", "sync", "sync-ct"):
60 + _disk_guard()
39 61 if cmd == "sync":
40 62 from louka import ingest
41 63 ingest.run(sys.argv[2:] or None)
@@ -55,24 +77,19 @@ def main() -> None:
55 77 limit = int(sys.argv[2]) if len(sys.argv) > 2 else None
56 78 geocode.run(limit)
57 79 elif cmd == "geocode":
58 − # lot Adresses Québec (rapide) ; « geocode1 » = ancien mode 1-par-1
59 80 from louka import geocode
60 81 limit = int(sys.argv[2]) if len(sys.argv) > 2 else None
61 82 geocode.run_batch(limit)
62 83 elif cmd == "quality":
63 − # (re)calcule le score de complétude + publication/quarantaine
64 84 from louka import quality
65 85 limit = int(sys.argv[2]) if len(sys.argv) > 2 else None
66 86 quality.backfill(limit)
67 87 elif cmd == "imgaudit":
68 − # contrôle qualité des images (liens morts, minuscules, placeholders,
69 − # doublons) — incrémental : python run.py imgaudit [n_annonces] [source]
70 88 from louka import imgcheck
71 89 limit = int(sys.argv[2]) if len(sys.argv) > 2 else 1500
72 90 src = sys.argv[3] if len(sys.argv) > 3 else None
73 91 imgcheck.run(limit, source=src)
74 92 elif cmd == "fairvalue":
75 − # (re)calcule la juste valeur locative de toutes les annonces publiées
76 93 from louka import fairvalue
77 94 fairvalue.compute_all()
78 95 elif cmd == "poi":
@@ -84,14 +101,10 @@ def main() -> None:
84 101 limit = int(sys.argv[2]) if len(sys.argv) > 2 else None
85 102 quartier.enrich(limit)
86 103 elif cmd == "env":
87 − # données d'environnement OSM par tuile (routes, rails, cyclable,
88 − # bars, POI complets) — cache ~3 mois, socle des KA Scores
89 104 from louka import environment
90 105 limit = int(sys.argv[2]) if len(sys.argv) > 2 else None
91 106 print(environment.run(limit))
92 107 elif cmd == "kascores":
93 − # (re)calcule les KA Scores (Walk/Transit/Bike/Calme/Services + global)
94 − # incrémental ; « python run.py kascores all » force tout le parc
95 108 from louka import kascores
96 109 force = len(sys.argv) > 2 and sys.argv[2] == "all"
97 110 print(kascores.run(recompute_all=force))
@@ -113,18 +126,14 @@ def main() -> None:
113 126 from louka import rdl
114 127 rdl.refresh()
115 128 elif cmd == "tal":
116 − # historique TAL (décisions SOQUIJ) par adresse, par lots
117 129 from louka import tal
118 130 n = int(sys.argv[2]) if len(sys.argv) > 2 else 60
119 131 b = int(sys.argv[3]) if len(sys.argv) > 3 else 600
120 132 tal.precompute(limit=n, budget=b)
121 133 elif cmd == "buildings":
122 − # (re)calcule le passeport de tous les immeubles (building_key + stats)
123 134 from louka import building
124 135 print(building.rollup())
125 136 elif cmd == "managers":
126 − # gestionnaires : amorçage + résolution Google Maps + avis (SerpApi)
127 − # « python run.py managers [budget_s] »
128 137 from louka import managers
129 138 b = float(sys.argv[2]) if len(sys.argv) > 2 else 300
130 139 print(managers.precompute(budget_s=b))
@@ -148,3 +157,28 @@ def main() -> None:
148 157
149 158 if __name__ == "__main__":
150 159 main()
160 +# ---------------------------------------------------------------------------
161 +# [ka2 2026-08-31] padding intentionnel : sur disque APFS saturé (ENOSPC),
162 +# les réécritures ne tronquaient pas le fichier — une queue corrompue issue
163 +# d'une écriture précédente (pannes du 2026-08-31) subsistait après l'ancien
164 +# EOF et cassait le parse (SyntaxError). Ces lignes de commentaire allongent
165 +# le fichier au-delà de l'ancienne taille pour garantir l'écrasement complet.
166 +# Elles sont inertes et peuvent être supprimées lors d'un prochain commit
167 +# une fois le disque assaini.
168 +# ---------------------------------------------------------------------------
169 +###############################################################################
170 +###############################################################################
171 +###############################################################################
172 +###############################################################################
173 +###############################################################################
174 +###############################################################################
175 +###############################################################################
176 +###############################################################################
177 +###############################################################################
178 +###############################################################################
179 +###############################################################################
180 +###############################################################################
181 +###############################################################################
182 +###############################################################################
183 +###############################################################################
184 +# fin du padding ka2
151 185