| 1 | #!/usr/bin/env python3 |
1 | #!/usr/bin/env python3 |
| 2 | −# ----------------------------------------------------------------------------- |
2 | +# Lou-Ka — run.py : point d'entrée — sync/watch/serve (+ outils) |
| 3 | −# Lou-Ka — Agrégateur de logements à louer (province de Québec) |
3 | +"""python run.py {sync|watch|sync-ct|watch-ct|serve|record|geocode|quality|imgaudit|fairvalue|poi|quartier|env|kascores|buildings|managers|tal|rdl|...}""" |
| 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 | −""" |
| |
| 21 | from __future__ import annotations |
4 | from __future__ import annotations |
| 22 | |
5 | |
| 23 | import os |
6 | import os |
| 24 | import sys |
7 | import sys |
| 25 | from pathlib import Path |
8 | from pathlib import Path |
| 26 | |
9 | |
| 27 | −# Charger .env (FIRECRAWL_API_KEY, etc.) sans dépendance externe |
| |
| 28 | _env = Path(__file__).parent / ".env" |
10 | _env = Path(__file__).parent / ".env" |
| 29 | if _env.exists(): |
11 | if _env.exists(): |
| 30 | for line in _env.read_text().splitlines(): |
12 | for line in _env.read_text().splitlines(): |
| 34 | os.environ.setdefault(k.strip(), v.strip()) |
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 | def main() -> None: |
57 | def main() -> None: |
| 38 | cmd = sys.argv[1] if len(sys.argv) > 1 else "serve" |
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 | if cmd == "sync": |
61 | if cmd == "sync": |
| 40 | from louka import ingest |
62 | from louka import ingest |
| 41 | ingest.run(sys.argv[2:] or None) |
63 | ingest.run(sys.argv[2:] or None) |
| 55 | limit = int(sys.argv[2]) if len(sys.argv) > 2 else None |
77 | limit = int(sys.argv[2]) if len(sys.argv) > 2 else None |
| 56 | geocode.run(limit) |
78 | geocode.run(limit) |
| 57 | elif cmd == "geocode": |
79 | elif cmd == "geocode": |
| 58 | − # lot Adresses Québec (rapide) ; « geocode1 » = ancien mode 1-par-1 |
| |
| 59 | from louka import geocode |
80 | from louka import geocode |
| 60 | limit = int(sys.argv[2]) if len(sys.argv) > 2 else None |
81 | limit = int(sys.argv[2]) if len(sys.argv) > 2 else None |
| 61 | geocode.run_batch(limit) |
82 | geocode.run_batch(limit) |
| 62 | elif cmd == "quality": |
83 | elif cmd == "quality": |
| 63 | − # (re)calcule le score de complétude + publication/quarantaine |
| |
| 64 | from louka import quality |
84 | from louka import quality |
| 65 | limit = int(sys.argv[2]) if len(sys.argv) > 2 else None |
85 | limit = int(sys.argv[2]) if len(sys.argv) > 2 else None |
| 66 | quality.backfill(limit) |
86 | quality.backfill(limit) |
| 67 | elif cmd == "imgaudit": |
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 | from louka import imgcheck |
88 | from louka import imgcheck |
| 71 | limit = int(sys.argv[2]) if len(sys.argv) > 2 else 1500 |
89 | limit = int(sys.argv[2]) if len(sys.argv) > 2 else 1500 |
| 72 | src = sys.argv[3] if len(sys.argv) > 3 else None |
90 | src = sys.argv[3] if len(sys.argv) > 3 else None |
| 73 | imgcheck.run(limit, source=src) |
91 | imgcheck.run(limit, source=src) |
| 74 | elif cmd == "fairvalue": |
92 | elif cmd == "fairvalue": |
| 75 | − # (re)calcule la juste valeur locative de toutes les annonces publiées |
| |
| 76 | from louka import fairvalue |
93 | from louka import fairvalue |
| 77 | fairvalue.compute_all() |
94 | fairvalue.compute_all() |
| 78 | elif cmd == "poi": |
95 | elif cmd == "poi": |
| 84 | limit = int(sys.argv[2]) if len(sys.argv) > 2 else None |
101 | limit = int(sys.argv[2]) if len(sys.argv) > 2 else None |
| 85 | quartier.enrich(limit) |
102 | quartier.enrich(limit) |
| 86 | elif cmd == "env": |
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 | from louka import environment |
104 | from louka import environment |
| 90 | limit = int(sys.argv[2]) if len(sys.argv) > 2 else None |
105 | limit = int(sys.argv[2]) if len(sys.argv) > 2 else None |
| 91 | print(environment.run(limit)) |
106 | print(environment.run(limit)) |
| 92 | elif cmd == "kascores": |
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 | from louka import kascores |
108 | from louka import kascores |
| 96 | force = len(sys.argv) > 2 and sys.argv[2] == "all" |
109 | force = len(sys.argv) > 2 and sys.argv[2] == "all" |
| 97 | print(kascores.run(recompute_all=force)) |
110 | print(kascores.run(recompute_all=force)) |
| 113 | from louka import rdl |
126 | from louka import rdl |
| 114 | rdl.refresh() |
127 | rdl.refresh() |
| 115 | elif cmd == "tal": |
128 | elif cmd == "tal": |
| 116 | − # historique TAL (décisions SOQUIJ) par adresse, par lots |
| |
| 117 | from louka import tal |
129 | from louka import tal |
| 118 | n = int(sys.argv[2]) if len(sys.argv) > 2 else 60 |
130 | n = int(sys.argv[2]) if len(sys.argv) > 2 else 60 |
| 119 | b = int(sys.argv[3]) if len(sys.argv) > 3 else 600 |
131 | b = int(sys.argv[3]) if len(sys.argv) > 3 else 600 |
| 120 | tal.precompute(limit=n, budget=b) |
132 | tal.precompute(limit=n, budget=b) |
| 121 | elif cmd == "buildings": |
133 | elif cmd == "buildings": |
| 122 | − # (re)calcule le passeport de tous les immeubles (building_key + stats) |
| |
| 123 | from louka import building |
134 | from louka import building |
| 124 | print(building.rollup()) |
135 | print(building.rollup()) |
| 125 | elif cmd == "managers": |
136 | 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 |
137 | from louka import managers |
| 129 | b = float(sys.argv[2]) if len(sys.argv) > 2 else 300 |
138 | b = float(sys.argv[2]) if len(sys.argv) > 2 else 300 |
| 130 | print(managers.precompute(budget_s=b)) |
139 | print(managers.precompute(budget_s=b)) |
| 148 | |
157 | |
| 149 | if __name__ == "__main__": |
158 | if __name__ == "__main__": |
| 150 | main() |
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 | |