SPB Git

spb/lou-ka Public

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

HTML 99.7%

PDF légendaires : fiche de propriété par annonce + rapport global du marché

- louka/pdfgen.py (reportlab) au style éditorial sharp : logo Lou·Ka dessiné
  (boîte lime tournée), tuiles à ombre décalée, chips encre/lime
- Fiche (/api/listings/{uid}/pdf, 2 pages) : prix géant + badge « vs le
  secteur », chips clés, photos encadrées, encadré « En bref » (text mining),
  description structurée, inclusions ✓, tuiles du quartier + barres de
  proximité + badges chaleur/criminalité, à proximité avec temps de marche,
  QR code vers la fiche en ligne, attributions de licences
- Rapport global (/api/stats/rapport.pdf, 3 pages) : grands chiffres,
  couverture par région avec barres, histogramme des loyers, par taille,
  top 20 villes et gestionnaires
- Boutons : « Télécharger la fiche (PDF) » sur chaque annonce, « Télécharger
  le rapport global (PDF) » sur la page Stats

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed yesterday (Aug 9, 2026) parent 0f0211a

Showing 6 changed files with +778 and −0

modified frontend/src/pages/Listing.tsx +4 −0
@@ -304,6 +304,10 @@ export default function ListingPage() {
304 304 target="_blank" rel="noopener noreferrer">
305 305 Voir l'annonce chez {sourceName(l.source)}
306 306 </a>
307 + <a className="btn btn-ghost btn-pdf"
308 + href={`/api/listings/${encodeURIComponent(l.uid)}/pdf`} download>
309 + 📄 Télécharger la fiche (PDF)
310 + </a>
307 311 </section>
308 312
309 313 <section className="f-bloc f-incl" id="inclusions">
modified frontend/src/pages/Stats.tsx +5 −0
@@ -163,6 +163,11 @@ export default function StatsPage() {
163 163 Calculé en direct sur les {fmt(t.total)} annonces actives agrégées par Lou-Ka.
164 164 Les loyers « à partir de » des sources sont utilisés tels quels.
165 165 </p>
166 + <p>
167 + <a className="btn btn-primary" href="/api/stats/rapport.pdf" download>
168 + 📊 Télécharger le rapport global (PDF)
169 + </a>
170 + </p>
166 171
167 172 <div className="tiles">
168 173 <div className="tile hero-tile">
modified frontend/src/styles.css +3 −0
@@ -926,3 +926,6 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */
926 926 .pass-dot { animation: none; opacity: 0.7; }
927 927 }
928 928 .pass-actions { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; margin-bottom: 14px; }
929 +
930 +/* --- boutons PDF -------------------------------------------------------- */
931 +.btn-pdf { display: block; text-align: center; margin-top: 10px; width: 100%; }
added louka/pdfgen.py +740 −0
@@ -0,0 +1,740 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# pdfgen.py : génération PDF au style « éditorial sharp » de Lou-Ka
5 +# - fiche_pdf(uid) : fiche de propriété (photos, prix + badge marché, chips,
6 +# description structurée, inclusions, quartier, à proximité, QR code)
7 +# - rapport_pdf() : rapport global du marché (totaux, régions, histogramme
8 +# des loyers, types, villes, gestionnaires)
9 +# Palette identique au site : crème #f5f3ee, encre #141814, lime #d9f26b,
10 +# vert #1c5c41. Polices de base (Helvetica/Courier) stylées par la mise en
11 +# page — aucune dépendance de police externe.
12 +# -----------------------------------------------------------------------------
13 +from __future__ import annotations
14 +
15 +import io
16 +import json
17 +import math
18 +import time
19 +
20 +import qrcode
21 +import requests
22 +from reportlab.lib.colors import HexColor
23 +from reportlab.lib.pagesizes import letter
24 +from reportlab.lib.utils import ImageReader
25 +from reportlab.pdfgen import canvas as rl_canvas
26 +
27 +from . import db
28 +
29 +# --- palette Lou-Ka ---------------------------------------------------------
30 +PAPER = HexColor("#f5f3ee")
31 +SURFACE = HexColor("#ffffff")
32 +INK = HexColor("#141814")
33 +INK2 = HexColor("#4d5551")
34 +INK3 = HexColor("#8b928c")
35 +GREEN = HexColor("#1c5c41")
36 +GREEN_DEEP = HexColor("#123f2e")
37 +LIME = HexColor("#d9f26b")
38 +LIME_SOFT = HexColor("#f0f9d2")
39 +AMBER = HexColor("#e8a33d")
40 +AMBER_SOFT = HexColor("#fdf3e2")
41 +
42 +PAGE_W, PAGE_H = letter
43 +M = 40 # marge
44 +IMG_TIMEOUT = 6
45 +UA = "LouKaBot/1.0 (+https://www.lou-ka.com/bot; contact@spboucher.ai)"
46 +
47 +POI_LABELS = {
48 + "epicerie": "Épicerie", "depanneur": "Dépanneur", "pharmacie": "Pharmacie",
49 + "ecole": "École", "garderie": "Garderie", "parc": "Parc", "bus": "Arrêt de bus",
50 + "metro": "Métro", "gym": "Gym", "cafe": "Café", "clinique": "Clinique",
51 + "hopital": "Hôpital", "bibliotheque": "Bibliothèque",
52 +}
53 +PROX_LABELS = [
54 + ("prox_epicerie", "Épiceries"), ("prox_transport", "Transport en commun"),
55 + ("prox_parc", "Parcs"), ("prox_ecole_prim", "Écoles primaires"),
56 + ("prox_sante", "Soins de santé"), ("prox_pharmacie", "Pharmacies"),
57 +]
58 +
59 +NBSP = " "
60 +
61 +
62 +def _fmt_money(v) -> str:
63 + return f"{v:,.0f}".replace(",", f"{NBSP}") + f"{NBSP}$" if v is not None else "—"
64 +
65 +
66 +def _fmt_dist(m_: float) -> str:
67 + return (f"{round(m_ / 10) * 10}{NBSP}m" if m_ < 1000
68 + else f"{m_ / 1000:.1f}".replace(".", ",") + f"{NBSP}km")
69 +
70 +
71 +def _marche(m_: float) -> str:
72 + return f"≈{NBSP}{max(1, round(m_ * 1.3 / 80))}{NBSP}min à pied"
73 +
74 +
75 +def _fetch_image(url: str) -> ImageReader | None:
76 + try:
77 + r = requests.get(url, timeout=IMG_TIMEOUT, headers={"User-Agent": UA})
78 + r.raise_for_status()
79 + return ImageReader(io.BytesIO(r.content))
80 + except Exception:
81 + return None
82 +
83 +
84 +class _Style:
85 + """Petits composants du style « éditorial sharp » sur un canvas."""
86 +
87 + def __init__(self, c: rl_canvas.Canvas):
88 + self.c = c
89 +
90 + def fond(self):
91 + self.c.setFillColor(PAPER)
92 + self.c.rect(0, 0, PAGE_W, PAGE_H, stroke=0, fill=1)
93 +
94 + def logo(self, x: float, y: float, taille: float = 22):
95 + """Wordmark Lou·Ka : « Lou » encre + boîte encre avec « Ka » lime."""
96 + c = self.c
97 + c.setFont("Helvetica-Bold", taille)
98 + c.setFillColor(INK)
99 + c.drawString(x, y, "Lou")
100 + w = c.stringWidth("Lou", "Helvetica-Bold", taille)
101 + bw = c.stringWidth("Ka", "Helvetica-Bold", taille) + 8
102 + c.saveState()
103 + c.translate(x + w + 3 + bw / 2, y + taille * 0.32)
104 + c.rotate(-3)
105 + c.setFillColor(INK)
106 + c.roundRect(-bw / 2, -taille * 0.62, bw, taille * 1.15, 4, stroke=0, fill=1)
107 + c.setFillColor(LIME)
108 + c.drawCentredString(0, -taille * 0.30, "Ka")
109 + c.restoreState()
110 + return x + w + 6 + bw
111 +
112 + def entete(self, titre: str):
113 + c = self.c
114 + self.logo(M, PAGE_H - M - 16)
115 + c.setFont("Courier-Bold", 8)
116 + c.setFillColor(INK3)
117 + c.drawRightString(PAGE_W - M, PAGE_H - M - 6,
118 + titre.upper())
119 + c.drawRightString(PAGE_W - M, PAGE_H - M - 16,
120 + time.strftime("GÉNÉRÉ LE %Y-%m-%d · WWW.LOU-KA.COM"))
121 + c.setStrokeColor(INK)
122 + c.setLineWidth(2)
123 + c.line(M, PAGE_H - M - 26, PAGE_W - M, PAGE_H - M - 26)
124 + return PAGE_H - M - 40 # y de départ du contenu
125 +
126 + def pied(self, texte: str, page: int | None = None):
127 + c = self.c
128 + c.setStrokeColor(INK)
129 + c.setLineWidth(1)
130 + c.line(M, M + 16, PAGE_W - M, M + 16)
131 + c.setFont("Helvetica", 6.5)
132 + c.setFillColor(INK3)
133 + c.drawString(M, M + 6, texte[:150])
134 + if page:
135 + c.setFont("Courier-Bold", 7)
136 + c.drawRightString(PAGE_W - M, M + 6, f"P.{page}")
137 +
138 + def pilule(self, x: float, y: float, texte: str, fg=LIME, bg=INK,
139 + stroke=None, taille: float = 8) -> float:
140 + """Chip arrondie ; retourne le x suivant."""
141 + c = self.c
142 + w = c.stringWidth(texte, "Helvetica-Bold", taille) + 14
143 + c.setFillColor(bg)
144 + if stroke:
145 + c.setStrokeColor(stroke)
146 + c.setLineWidth(1.2)
147 + c.roundRect(x, y, w, taille + 9, (taille + 9) / 2,
148 + stroke=1 if stroke else 0, fill=1)
149 + c.setFillColor(fg)
150 + c.setFont("Helvetica-Bold", taille)
151 + c.drawString(x + 7, y + 5, texte)
152 + return x + w + 6
153 +
154 + def titre_section(self, y: float, texte: str) -> float:
155 + c = self.c
156 + c.setFont("Helvetica-Bold", 12.5)
157 + c.setFillColor(INK)
158 + c.drawString(M, y, texte)
159 + c.setStrokeColor(LIME)
160 + c.setLineWidth(3)
161 + w = c.stringWidth(texte, "Helvetica-Bold", 12.5)
162 + c.line(M, y - 4, M + w, y - 4)
163 + return y - 18
164 +
165 + def paragraphe(self, x: float, y: float, texte: str, largeur: float,
166 + taille: float = 8.5, couleur=INK2, interligne: float = 11.5,
167 + max_lignes: int = 100) -> float:
168 + """Texte multi-lignes avec découpe aux mots ; retourne le y suivant."""
169 + c = self.c
170 + c.setFont("Helvetica", taille)
171 + c.setFillColor(couleur)
172 + mots = texte.split()
173 + ligne = ""
174 + n = 0
175 + for mot in mots:
176 + essai = (ligne + " " + mot).strip()
177 + if c.stringWidth(essai, "Helvetica", taille) <= largeur:
178 + ligne = essai
179 + else:
180 + c.drawString(x, y, ligne)
181 + y -= interligne
182 + n += 1
183 + ligne = mot
184 + if n >= max_lignes - 1:
185 + ligne += " …"
186 + break
187 + if ligne:
188 + c.drawString(x, y, ligne)
189 + y -= interligne
190 + return y
191 +
192 +
193 +# ---------------------------------------------------------------------------
194 +# Fiche de propriété
195 +# ---------------------------------------------------------------------------
196 +
197 +def fiche_pdf(uid: str) -> bytes | None:
198 + """PDF de la fiche du logement `uid`. None si introuvable."""
199 + con = db.connect()
200 + row = con.execute("SELECT * FROM listings WHERE uid=?", (uid,)).fetchone()
201 + if row is None:
202 + con.close()
203 + return None
204 + l = dict(row)
205 + l["amenities"] = json.loads(l.get("amenities") or "[]")
206 + l["images"] = json.loads(l.get("images") or "[]")
207 + l["details"] = json.loads(l.get("details") or "{}")
208 + l["digest"] = json.loads(l["digest"]) if l.get("digest") else None
209 + poi = []
210 + quartier = None
211 + if l.get("lat") is not None:
212 + key = f"{round(l['lat'], 4)},{round(l['lng'], 4)}"
213 + pr = con.execute("SELECT pois FROM poi_cache WHERE coord_key=?", (key,)).fetchone()
214 + poi = json.loads(pr["pois"]) if pr else []
215 + from . import quartier as qmod
216 + dauid = l.get("dauid")
217 + quartier = qmod.fiche_quartier(l["lat"], l["lng"], l.get("city") or "",
218 + dauid if dauid and dauid != "hors-zone" else None)
219 + reg = json.loads((db.DB_PATH.parent / "sources.json").read_text("utf-8"))["sources"]
220 + src_name = next((s["name"] for s in reg if s["id"] == l["source"]), l["source"])
221 + con.close()
222 +
223 + buf = io.BytesIO()
224 + c = rl_canvas.Canvas(buf, pagesize=letter)
225 + c.setTitle(f"Lou-Ka — {l.get('title') or l.get('address')}")
226 + s = _Style(c)
227 + s.fond()
228 + y = s.entete("Fiche logement")
229 +
230 + # --- bloc prix + badge marché
231 + prix_txt = (_fmt_money(l["price"]) + f"{NBSP}/ mois") if l.get("price") \
232 + else (l.get("price_label") or "Prix sur demande")
233 + c.setFont("Helvetica-Bold", 30)
234 + c.setFillColor(INK)
235 + c.drawString(M, y - 24, prix_txt)
236 + loyer_secteur = (quartier or {}).get("demographie", {}).get("loyer_moyen") \
237 + if quartier else None
238 + bx = M + c.stringWidth(prix_txt, "Helvetica-Bold", 30) + 14
239 + if l.get("price") and loyer_secteur:
240 + delta = (l["price"] - loyer_secteur) / loyer_secteur
241 + pct = f"{'+' if delta > 0 else '−'}{abs(round(delta * 100))}{NBSP}%"
242 + if delta <= -0.15:
243 + s.pilule(bx, y - 22, f"{pct} vs secteur · Bon deal", GREEN_DEEP, LIME_SOFT, GREEN)
244 + elif delta <= 0.10:
245 + s.pilule(bx, y - 22, f"{pct} vs secteur · Dans le marché", INK2, SURFACE, INK)
246 + else:
247 + s.pilule(bx, y - 22, f"{pct} vs secteur · Au-dessus du marché",
248 + HexColor("#8a5a12"), AMBER_SOFT, AMBER)
249 + y -= 44
250 +
251 + # --- titre + adresse
252 + c.setFont("Helvetica-Bold", 13)
253 + c.setFillColor(INK)
254 + c.drawString(M, y, (l.get("title") or l.get("address") or "")[:80])
255 + y -= 14
256 + c.setFont("Helvetica", 9)
257 + c.setFillColor(INK2)
258 + loc = " · ".join(x for x in (l.get("address"), l.get("sector"), l.get("city"))
259 + if x and x != l.get("title"))
260 + c.drawString(M, y, loc[:110])
261 + y -= 20
262 +
263 + # --- chips clés
264 + x = M
265 + chips = []
266 + if l.get("unit_type"):
267 + chips.append(l["unit_type"])
268 + ad = l.get("availability_date")
269 + if ad == "now":
270 + chips.append("Libre maintenant")
271 + elif ad:
272 + chips.append(f"Dispo {ad}")
273 + if l.get("area_sqft"):
274 + chips.append(f"{round(l['area_sqft'])}{NBSP}pi²")
275 + if l.get("furnished"):
276 + chips.append("Meublé")
277 + if l.get("pets"):
278 + chips.append({"oui": "Animaux acceptés", "non": "Animaux refusés",
279 + "conditions": "Animaux sous conditions"}.get(l["pets"], l["pets"]))
280 + if l["details"].get("floor") is not None:
281 + chips.append(f"{l['details']['floor']}e étage")
282 + for chip in chips[:7]:
283 + x = s.pilule(x, y - 6, chip)
284 + y -= 30
285 +
286 + # --- photos : 1 grande + 3 vignettes
287 + imgs = [im for im in (_fetch_image(u) for u in l["images"][:4]) if im]
288 + if imgs:
289 + big_h = 170
290 + big_w = big_h * 16 / 10.5
291 + c.setStrokeColor(INK)
292 + c.setLineWidth(1.5)
293 + try:
294 + c.drawImage(imgs[0], M, y - big_h, big_w, big_h,
295 + preserveAspectRatio=True, anchor="c", mask="auto")
296 + except Exception:
297 + pass
298 + c.rect(M, y - big_h, big_w, big_h, stroke=1, fill=0)
299 + tx = M + big_w + 10
300 + th = (big_h - 16) / 3
301 + for i, im in enumerate(imgs[1:4]):
302 + ty = y - (i + 1) * th - i * 8
303 + try:
304 + c.drawImage(im, tx, ty, PAGE_W - M - tx, th,
305 + preserveAspectRatio=True, anchor="c", mask="auto")
306 + c.rect(tx, ty, PAGE_W - M - tx, th, stroke=1, fill=0)
307 + except Exception:
308 + pass
309 + y -= big_h + 18
310 +
311 + # --- « En bref » (digest) sur bandeau lime
312 + dg = l.get("digest")
313 + if dg and dg.get("en_bref"):
314 + c.setFillColor(LIME_SOFT)
315 + c.setStrokeColor(GREEN)
316 + c.setLineWidth(1.2)
317 + c.roundRect(M, y - 40, PAGE_W - 2 * M, 44, 6, stroke=1, fill=1)
318 + s.paragraphe(M + 10, y - 8, "EN BREF — " + dg["en_bref"],
319 + PAGE_W - 2 * M - 20, taille=8.5, couleur=GREEN_DEEP,
320 + max_lignes=3)
321 + y -= 54
322 +
323 + # --- description (2-3 sections max) | inclusions en colonne droite
324 + col_g = PAGE_W * 0.56 - M
325 + y_desc = s.titre_section(y, "Description")
326 + if dg and dg.get("sections"):
327 + for sec in dg["sections"][:3]:
328 + c.setFont("Helvetica-Bold", 9)
329 + c.setFillColor(INK)
330 + c.drawString(M, y_desc, sec["titre"][:60])
331 + y_desc -= 12
332 + y_desc = s.paragraphe(M, y_desc, sec["texte"], col_g, max_lignes=6)
333 + y_desc -= 4
334 + if y_desc < 150:
335 + break
336 + elif l.get("description"):
337 + y_desc = s.paragraphe(M, y_desc, l["description"], col_g, max_lignes=16)
338 +
339 + xr = M + col_g + 24
340 + y_incl = s_titre_droite(c, y, xr, "Inclusions et commodités")
341 + d = l["details"]
342 + badges = []
343 + inc = d.get("inclusions") or {}
344 + for k, lab in (("heating", "Chauffage inclus"), ("electricity", "Électricité incluse"),
345 + ("hot_water", "Eau chaude incluse"), ("internet", "Internet inclus")):
346 + if inc.get(k):
347 + badges.append(lab)
348 + app = d.get("appliances") or {}
349 + if app.get("dishwasher"):
350 + badges.append("Lave-vaisselle")
351 + if app.get("washer_dryer"):
352 + badges.append("Laveuse-sécheuse")
353 + for k, lab in (("ac", "Climatisation"), ("elevator", "Ascenseur"),
354 + ("balcony", "Balcon"), ("pool", "Piscine"), ("gym", "Gym"),
355 + ("laundry", "Buanderie"), ("storage", "Rangement")):
356 + if d.get(k):
357 + badges.append(lab)
358 + if (d.get("parking") or {}).get("available"):
359 + badges.append("Stationnement")
360 + if l.get("furnished"):
361 + badges.append("Meublé")
362 + c.setFont("Helvetica", 8.5)
363 + for b in badges[:12]:
364 + c.setFillColor(GREEN)
365 + c.drawString(xr, y_incl, "✓")
366 + c.setFillColor(INK2)
367 + c.drawString(xr + 12, y_incl, b)
368 + y_incl -= 12
369 + autres = [a for a in l["amenities"]
370 + if not any(b.lower() in a.lower() or a.lower() in b.lower() for b in badges)]
371 + for a in autres[: max(0, 14 - len(badges))]:
372 + c.setFillColor(INK3)
373 + c.drawString(xr + 12, y_incl, a[:38])
374 + y_incl -= 11
375 +
376 + y = min(y_desc, y_incl) - 8
377 +
378 + # --- pied page 1 + page 2 (quartier / à proximité / références)
379 + s.pied("Données de l'annonce publiée par le gestionnaire — Lou-Ka est un "
380 + "agrégateur indépendant ; chaque fiche renvoie à l'annonce originale.", 1)
381 + c.showPage()
382 + s.fond()
383 + y = s.entete("Fiche logement · suite")
384 +
385 + # Le quartier
386 + if quartier:
387 + y = s.titre_section(y, "Le quartier")
388 + demo = quartier.get("demographie") or {}
389 + tuiles = [("Revenu médian", _fmt_money(demo.get("revenu_median"))),
390 + ("Ménages locataires", f"{round(demo['pct_locataires'])}{NBSP}%" if demo.get("pct_locataires") is not None else "—"),
391 + ("Loyer moyen secteur", _fmt_money(demo.get("loyer_moyen"))),
392 + ("Âge médian", f"{round(demo['age_median'])} ans" if demo.get("age_median") is not None else "—"),
393 + ("Français à la maison", f"{round(demo['pct_francais'])}{NBSP}%" if demo.get("pct_francais") is not None else "—"),
394 + ("Diplôme universitaire", f"{round(demo['pct_univ'])}{NBSP}%" if demo.get("pct_univ") is not None else "—")]
395 + tw = (PAGE_W - 2 * M - 20) / 3
396 + for i, (lab, val) in enumerate(tuiles):
397 + tx = M + (i % 3) * (tw + 10)
398 + ty = y - 34 - (i // 3) * 44
399 + c.setFillColor(SURFACE)
400 + c.setStrokeColor(INK)
401 + c.setLineWidth(1.2)
402 + c.roundRect(tx, ty, tw, 38, 5, stroke=1, fill=1)
403 + c.setFont("Helvetica-Bold", 12)
404 + c.setFillColor(INK)
405 + c.drawString(tx + 8, ty + 20, val)
406 + c.setFont("Courier", 6.5)
407 + c.setFillColor(INK3)
408 + c.drawString(tx + 8, ty + 8, lab.upper())
409 + y -= 34 + 2 * 44 + 6
410 +
411 + prox = quartier.get("proximite") or {}
412 + for k, lab in PROX_LABELS:
413 + if k not in prox:
414 + continue
415 + v = max(0.0, min(1.0, prox[k]))
416 + c.setFont("Helvetica", 8)
417 + c.setFillColor(INK2)
418 + c.drawString(M, y, lab)
419 + bx0, bw_ = M + 130, PAGE_W - 2 * M - 160
420 + c.setFillColor(SURFACE)
421 + c.setStrokeColor(INK)
422 + c.setLineWidth(0.8)
423 + c.roundRect(bx0, y - 1, bw_, 7, 3.5, stroke=1, fill=1)
424 + c.setFillColor(GREEN)
425 + c.roundRect(bx0, y - 1, bw_ * v, 7, 3.5, stroke=0, fill=1)
426 + c.setFont("Courier-Bold", 7.5)
427 + c.setFillColor(INK)
428 + c.drawRightString(PAGE_W - M, y, str(round(v * 100)))
429 + y -= 14
430 + y -= 4
431 + badges_q = []
432 + ch = quartier.get("chaleur")
433 + if ch:
434 + if ch["classe"] <= 3:
435 + badges_q.append(("Îlot de fraîcheur", GREEN_DEEP, LIME_SOFT, GREEN))
436 + elif ch["classe"] >= 7:
437 + badges_q.append((f"Îlot de chaleur (+{ch['ecart']:.1f} °C)".replace(".", ","),
438 + HexColor("#8a5a12"), AMBER_SOFT, AMBER))
439 + cr = quartier.get("crime")
440 + if cr and cr.get("type") == "igc" and cr.get("indice_canada"):
441 + dlt = round(100 * (cr["indice"] - cr["indice_canada"]) / cr["indice_canada"])
442 + badges_q.append((f"Criminalité {abs(dlt)}{NBSP}% "
443 + f"{'sous' if dlt <= 0 else 'au-dessus de'} la moyenne canadienne",
444 + INK2, SURFACE, INK))
445 + elif cr and cr.get("type") == "points":
446 + badges_q.append((f"{cr['douze_mois']} actes criminels à <500 m (12 mois)",
447 + INK2, SURFACE, INK))
448 + x = M
449 + for txt, fg, bg, stk in badges_q:
450 + x = s.pilule(x, y - 8, txt, fg, bg, stk)
451 + y -= 30
452 +
453 + # À proximité
454 + if poi:
455 + y = s.titre_section(y, "À proximité")
456 + col2 = y
457 + for i, p in enumerate(poi[:12]):
458 + px = M if i % 2 == 0 else M + (PAGE_W - 2 * M) / 2 + 10
459 + if i % 2 == 0 and i > 0:
460 + y -= 13
461 + py = y
462 + c.setFont("Helvetica", 8)
463 + c.setFillColor(INK2)
464 + lab = POI_LABELS.get(p["cat"], p["cat"])
465 + nom_poi = p["name"][:19] + "…" if len(p["name"]) > 20 else p["name"]
466 + c.drawString(px, py, f"{lab} — {nom_poi}")
467 + c.setFont("Courier-Bold", 7.5)
468 + c.setFillColor(INK)
469 + c.drawRightString(px + (PAGE_W - 2 * M) / 2 - 14, py,
470 + f"{_fmt_dist(p['dist_m'])} · {_marche(p['dist_m'])}")
471 + y -= 26
472 +
473 + # Références : QR + gestionnaire + source
474 + y = s.titre_section(y, "Références")
475 + qr = qrcode.make(f"https://www.lou-ka.com/logement/{uid}", box_size=4, border=1)
476 + qb = io.BytesIO()
477 + qr.save(qb, format="PNG")
478 + c.drawImage(ImageReader(io.BytesIO(qb.getvalue())), M, y - 74, 70, 70)
479 + c.setFont("Helvetica-Bold", 9)
480 + c.setFillColor(INK)
481 + c.drawString(M + 82, y - 14, f"Gestionnaire : {src_name}")
482 + c.setFont("Helvetica", 8)
483 + c.setFillColor(INK2)
484 + c.drawString(M + 82, y - 28, "Annonce originale :")
485 + c.setFillColor(GREEN)
486 + c.drawString(M + 82, y - 40, (l.get("url") or "")[:90])
487 + c.setFillColor(INK2)
488 + c.drawString(M + 82, y - 56, "Fiche à jour en ligne (scannez le code) :")
489 + c.setFillColor(GREEN)
490 + c.drawString(M + 82, y - 68, f"www.lou-ka.com/logement/{uid}")
491 +
492 + s.pied("Quartier : Statistique Canada (Recensement 2021), INSPQ (CC-BY 4.0)"
493 + + (", Ville de Montréal (CC-BY 4.0)" if (quartier or {}).get("crime", {}).get("type") == "points" else "")
494 + + " · Commodités : OpenStreetMap. Prix et disponibilités : ceux affichés par la source.", 2)
495 + c.save()
496 + return buf.getvalue()
497 +
498 +
499 +def s_titre_droite(c, y, x, texte):
500 + c.setFont("Helvetica-Bold", 12.5)
501 + c.setFillColor(INK)
502 + c.drawString(x, y, texte)
503 + c.setStrokeColor(LIME)
504 + c.setLineWidth(3)
505 + c.line(x, y - 4, x + c.stringWidth(texte, "Helvetica-Bold", 12.5), y - 4)
506 + return y - 18
507 +
508 +
509 +# ---------------------------------------------------------------------------
510 +# Rapport global du marché
511 +# ---------------------------------------------------------------------------
512 +
513 +_REGIONS = [
514 + ("Québec métro", {"Québec", "Lévis", "Saint-Augustin-de-Desmaures", "L'Ancienne-Lorette", "Pont-Rouge", "Shannon"}),
515 + ("Outaouais", {"Gatineau", "Chelsea", "Thurso", "Perkins", "Maniwaki", "Val-des-Monts"}),
516 + ("Estrie / Montérégie-Est", {"Sherbrooke", "Magog", "Orford", "East Angus", "Waterville", "Granby", "Waterloo", "Bromont", "Cowansville", "Richmond"}),
517 + ("Mauricie / Centre-du-Québec", {"Trois-Rivières", "Bécancour", "Shawinigan", "Drummondville", "Victoriaville", "Notre-Dame-du-Bon-Conseil", "Saint-Léonard-d'Aston", "Louiseville", "Nicolet", "Wickham", "Saint-Narcisse", "Saint-Nicéphore"}),
518 + ("Lanaudière / Laurentides", {"Joliette", "Saint-Jérôme", "Berthierville", "Saint-Ambroise-de-Kildare", "Saint-Gabriel-de-Brandon", "Lachute", "Brownsburg-Chatham", "Saint-Charles-Borromée", "Mirabel", "Sainte-Agathe-des-Monts", "Sainte-Thérèse", "Blainville", "Charlemagne", "Notre-Dame-des-Prairies"}),
519 + ("Bas-Saint-Laurent / Gaspésie", {"Rimouski", "Rivière-du-Loup", "Matane", "Saint-Ulric", "Amqui", "Le Bic", "New Richmond", "Carleton-sur-Mer", "Gaspé"}),
520 + ("Saguenay–Lac-Saint-Jean", {"Saguenay", "Alma", "Chicoutimi", "Jonquière", "Chambord", "La Malbaie"}),
521 + ("Abitibi-Témiscamingue", {"Rouyn-Noranda", "Val-d'Or", "Amos", "Malartic"}),
522 + ("Côte-Nord", {"Sept-Îles", "Port-Cartier", "Baie-Comeau", "Forestville"}),
523 + ("Chaudière-Appalaches", {"Saint-Georges", "Sainte-Marie", "Thetford Mines", "Montmagny", "Vallée-Jonction", "Scott", "Saint-Isidore", "La Guadeloupe", "Saint-Raphaël"}),
524 +]
525 +
526 +
527 +def _region_de(city: str) -> str:
528 + for nom, villes in _REGIONS:
529 + if city in villes:
530 + return nom
531 + return "Grand Montréal & environs"
532 +
533 +
534 +def rapport_pdf() -> bytes:
535 + """Rapport global du marché locatif Lou-Ka (multi-pages)."""
536 + con = db.connect()
537 + rows = con.execute(
538 + "SELECT city, source, price, unit_type FROM listings WHERE active=1").fetchall()
539 + reg_file = json.loads((db.DB_PATH.parent / "sources.json").read_text("utf-8"))["sources"]
540 + noms = {s["id"]: s["name"] for s in reg_file}
541 + n_sources = con.execute(
542 + "SELECT COUNT(DISTINCT source) c FROM listings WHERE active=1").fetchone()["c"]
543 + gps = con.execute(
544 + "SELECT ROUND(100.0*SUM(lat IS NOT NULL)/COUNT(*),1) p FROM listings WHERE active=1"
545 + ).fetchone()["p"]
546 + con.close()
547 +
548 + total = len(rows)
549 + prix = sorted(r["price"] for r in rows if r["price"] and 300 <= r["price"] <= 10000)
550 + moy = round(sum(prix) / len(prix)) if prix else 0
551 + med = round(prix[len(prix) // 2]) if prix else 0
552 +
553 + par_region: dict[str, dict] = {}
554 + for r in rows:
555 + reg = _region_de(r["city"] or "")
556 + d = par_region.setdefault(reg, {"n": 0, "prix": [], "sources": set()})
557 + d["n"] += 1
558 + d["sources"].add(r["source"])
559 + if r["price"] and 300 <= r["price"] <= 10000:
560 + d["prix"].append(r["price"])
561 +
562 + par_ville: dict[str, int] = {}
563 + par_source: dict[str, int] = {}
564 + par_type: dict[str, list] = {}
565 + for r in rows:
566 + par_ville[r["city"] or "?"] = par_ville.get(r["city"] or "?", 0) + 1
567 + par_source[r["source"]] = par_source.get(r["source"], 0) + 1
568 + if r["unit_type"]:
569 + par_type.setdefault(r["unit_type"], []).append(r["price"])
570 +
571 + buf = io.BytesIO()
572 + c = rl_canvas.Canvas(buf, pagesize=letter)
573 + c.setTitle("Lou-Ka — Rapport du marché locatif")
574 + s = _Style(c)
575 +
576 + # ---- page 1 : couverture + grands chiffres + régions
577 + s.fond()
578 + s.logo(M, PAGE_H - 130, 42)
579 + c.setFont("Helvetica-Bold", 24)
580 + c.setFillColor(INK)
581 + c.drawString(M, PAGE_H - 185, "Rapport du marché locatif")
582 + c.setFillColor(SURFACE)
583 + c.setStrokeColor(INK)
584 + c.setFont("Courier-Bold", 9)
585 + c.setFillColor(INK3)
586 + c.drawString(M, PAGE_H - 205,
587 + time.strftime("PROVINCE DE QUÉBEC · GÉNÉRÉ LE %Y-%m-%d · WWW.LOU-KA.COM"))
588 +
589 + grands = [(f"{total:,}".replace(",", NBSP), "annonces actives"),
590 + (str(n_sources), "gestionnaires connectés"),
591 + (_fmt_money(moy), "loyer moyen"),
592 + (_fmt_money(med), "loyer médian"),
593 + (f"{gps}{NBSP}%", "annonces géolocalisées")]
594 + tw = (PAGE_W - 2 * M - 4 * 10) / 5
595 + for i, (val, lab) in enumerate(grands):
596 + tx = M + i * (tw + 10)
597 + ty = PAGE_H - 285
598 + c.setFillColor(INK)
599 + c.roundRect(tx + 3, ty - 3, tw, 58, 6, stroke=0, fill=1) # ombre décalée
600 + c.setFillColor(SURFACE)
601 + c.setStrokeColor(INK)
602 + c.setLineWidth(1.5)
603 + c.roundRect(tx, ty, tw, 58, 6, stroke=1, fill=1)
604 + c.setFont("Helvetica-Bold", 15)
605 + c.setFillColor(INK)
606 + c.drawCentredString(tx + tw / 2, ty + 32, val)
607 + c.setFont("Courier", 6.3)
608 + c.setFillColor(INK3)
609 + c.drawCentredString(tx + tw / 2, ty + 14, lab.upper())
610 +
611 + y = s.titre_section(PAGE_H - 330, "Couverture par région")
612 + c.setFont("Courier-Bold", 7)
613 + c.setFillColor(INK3)
614 + for lab, xoff in (("RÉGION", 0), ("ANNONCES", 280), ("SOURCES", 360),
615 + ("LOYER MOYEN", 430)):
616 + c.drawString(M + xoff, y, lab)
617 + y -= 4
618 + c.setStrokeColor(INK)
619 + c.setLineWidth(1)
620 + c.line(M, y, PAGE_W - M, y)
621 + y -= 14
622 + ordre = sorted(par_region.items(), key=lambda kv: -kv[1]["n"])
623 + max_n = max(d["n"] for _, d in ordre)
624 + for nom, d in ordre:
625 + c.setFont("Helvetica-Bold", 8.5)
626 + c.setFillColor(INK)
627 + c.drawString(M, y, nom)
628 + # barre proportionnelle
629 + c.setFillColor(LIME)
630 + c.setStrokeColor(INK)
631 + c.setLineWidth(0.7)
632 + bw = 90 * d["n"] / max_n
633 + c.roundRect(M + 180, y - 1, max(3, bw), 8, 3, stroke=1, fill=1)
634 + c.setFont("Helvetica", 8.5)
635 + c.setFillColor(INK2)
636 + c.drawRightString(M + 330, y, f"{d['n']:,}".replace(",", NBSP))
637 + c.drawRightString(M + 400, y, str(len(d["sources"])))
638 + pm = round(sum(d["prix"]) / len(d["prix"])) if d["prix"] else None
639 + c.drawRightString(M + 500, y, _fmt_money(pm) if pm else "—")
640 + y -= 15
641 + s.pied("Rapport généré automatiquement à partir des annonces publiques "
642 + "agrégées par Lou-Ka. Loyers : bornes 300–10 000 $.", 1)
643 + c.showPage()
644 +
645 + # ---- page 2 : histogramme des loyers + par type
646 + s.fond()
647 + y = s.entete("Rapport du marché · loyers")
648 + y = s.titre_section(y, "Distribution des loyers")
649 + lo, hi, step = 400, 3200, 200
650 + classes = [0] * ((hi - lo) // step + 2)
651 + for p in prix:
652 + if p < lo:
653 + classes[0] += 1
654 + elif p >= hi:
655 + classes[-1] += 1
656 + else:
657 + classes[1 + int((p - lo) // step)] += 1
658 + max_c = max(classes) or 1
659 + ch_h, ch_y = 150, y - 170
660 + bw = (PAGE_W - 2 * M) / len(classes)
661 + for i, n in enumerate(classes):
662 + bh = ch_h * n / max_c
663 + bx = M + i * bw
664 + c.setFillColor(GREEN if i not in (0, len(classes) - 1) else INK3)
665 + c.setStrokeColor(INK)
666 + c.setLineWidth(0.6)
667 + c.rect(bx + 2, ch_y, bw - 4, max(1, bh), stroke=1, fill=1)
668 + if n and n > max_c * 0.06:
669 + c.setFont("Courier-Bold", 6)
670 + c.setFillColor(INK)
671 + c.drawCentredString(bx + bw / 2, ch_y + bh + 3, str(n))
672 + c.setFont("Helvetica", 5.6)
673 + c.setFillColor(INK3)
674 + lab = "<400" if i == 0 else (f"{hi}+" if i == len(classes) - 1
675 + else str(lo + (i - 1) * step))
676 + c.drawCentredString(bx + bw / 2, ch_y - 9, lab)
677 + y = ch_y - 30
678 +
679 + y = s.titre_section(y, "Par taille de logement")
680 + types = sorted(par_type.items(), key=lambda kv: -len(kv[1]))[:9]
681 + c.setFont("Courier-Bold", 7)
682 + c.setFillColor(INK3)
683 + for lab, xoff in (("TAILLE", 0), ("ANNONCES", 160), ("LOYER MOYEN", 260),
684 + ("LOYER MIN", 360)):
685 + c.drawString(M + xoff, y, lab)
686 + y -= 4
687 + c.line(M, y, PAGE_W - M, y)
688 + y -= 13
689 + for t, ps in types:
690 + pv = [p for p in ps if p and 300 <= p <= 10000]
691 + c.setFont("Helvetica-Bold", 8.5)
692 + c.setFillColor(INK)
693 + c.drawString(M, y, t)
694 + c.setFont("Helvetica", 8.5)
695 + c.setFillColor(INK2)
696 + c.drawRightString(M + 220, y, str(len(ps)))
697 + c.drawRightString(M + 330, y, _fmt_money(round(sum(pv) / len(pv))) if pv else "—")
698 + c.drawRightString(M + 430, y, _fmt_money(min(pv)) if pv else "—")
699 + y -= 13
700 + s.pied("Lou-Ka — agrégateur indépendant. Chaque annonce renvoie à la "
701 + "source originale du gestionnaire.", 2)
702 + c.showPage()
703 +
704 + # ---- page 3 : top villes + top gestionnaires
705 + s.fond()
706 + y = s.entete("Rapport du marché · détail")
707 + y = s.titre_section(y, "Top 20 des villes")
708 + top_v = sorted(par_ville.items(), key=lambda kv: -kv[1])[:20]
709 + col_w = (PAGE_W - 2 * M) / 2
710 + for i, (v, n) in enumerate(top_v):
711 + vx = M if i < 10 else M + col_w
712 + vy = y - (i % 10) * 13
713 + c.setFont("Helvetica", 8.5)
714 + c.setFillColor(INK2)
715 + c.drawString(vx, vy, f"{i + 1:>2}. {v}")
716 + c.setFont("Courier-Bold", 8)
717 + c.setFillColor(INK)
718 + c.drawRightString(vx + col_w - 24, vy, f"{n:,}".replace(",", NBSP))
719 + y -= 10 * 13 + 16
720 +
721 + y = s.titre_section(y, "Top 20 des gestionnaires")
722 + top_s = sorted(par_source.items(), key=lambda kv: -kv[1])[:20]
723 + for i, (sid, n) in enumerate(top_s):
724 + vx = M if i < 10 else M + col_w
725 + vy = y - (i % 10) * 13
726 + c.setFont("Helvetica", 8.5)
727 + c.setFillColor(INK2)
728 + c.drawString(vx, vy, f"{i + 1:>2}. {noms.get(sid, sid)[:34]}")
729 + c.setFont("Courier-Bold", 8)
730 + c.setFillColor(INK)
731 + c.drawRightString(vx + col_w - 24, vy, f"{n:,}".replace(",", NBSP))
732 + y -= 10 * 13 + 20
733 +
734 + c.setFont("Helvetica", 7.5)
735 + c.setFillColor(INK3)
736 + c.drawString(M, y, "Sources de données de quartier : Statistique Canada (Recensement 2021, "
737 + "licence ouverte), INSPQ (CC-BY 4.0), Ville de Montréal (CC-BY 4.0), OpenStreetMap.")
738 + s.pied("© Lou-Ka — www.lou-ka.com · rapport non contractuel, généré automatiquement.", 3)
739 + c.save()
740 + return buf.getvalue()
modified louka/web.py +23 −0
@@ -167,6 +167,29 @@ def listings_geojson(
167 167 return {"type": "FeatureCollection", "features": features}
168 168
169 169
170 +@app.get("/api/listings/{uid}/pdf")
171 +def listing_pdf(uid: str):
172 + """Fiche de propriété PDF (photos, prix, quartier, QR) — voir pdfgen.py."""
173 + from fastapi.responses import Response
174 + from . import pdfgen
175 + data = pdfgen.fiche_pdf(uid)
176 + if data is None:
177 + raise HTTPException(404, "Annonce introuvable")
178 + nom = uid.replace(":", "-")
179 + return Response(content=data, media_type="application/pdf", headers={
180 + "Content-Disposition": f'attachment; filename="louka-fiche-{nom}.pdf"'})
181 +
182 +
183 +@app.get("/api/stats/rapport.pdf")
184 +def rapport_marche_pdf():
185 + """Rapport global du marché locatif (PDF multi-pages)."""
186 + from fastapi.responses import Response
187 + from . import pdfgen
188 + return Response(content=pdfgen.rapport_pdf(), media_type="application/pdf",
189 + headers={"Content-Disposition":
190 + 'attachment; filename="louka-rapport-marche.pdf"'})
191 +
192 +
170 193 @app.get("/api/listings/{uid}")
171 194 def get_listing(uid: str):
172 195 con = db.connect()
modified requirements.txt +3 −0
@@ -4,3 +4,6 @@ fastapi>=0.110
4 4 uvicorn>=0.29
5 5 requests>=2.31
6 6 beautifulsoup4>=4.12
7 +reportlab>=4.0
8 +pillow>=10.0
9 +qrcode>=7.4
7 10