| 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 |
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 |
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 |
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 |
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 |
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 |
|