SPB Git

spb/lou-ka

Public

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

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 (Aug 9, 2026) parent 0f0211a Browse files

6 changed files +778 −0

modified frontend/src/pages/Listing.tsx +4 −0
@@ -304,6 +304,10 @@ export default function ListingPage() {
304 target="_blank" rel="noopener noreferrer"> 304 target="_blank" rel="noopener noreferrer">
305 Voir l'annonce chez {sourceName(l.source)} ↗ 305 Voir l'annonce chez {sourceName(l.source)} ↗
306 </a> 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 </section> 311 </section>
308 312
309 <section className="f-bloc f-incl" id="inclusions"> 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 Calculé en direct sur les {fmt(t.total)} annonces actives agrégées par Lou-Ka. 163 Calculé en direct sur les {fmt(t.total)} annonces actives agrégées par Lou-Ka.
164 Les loyers « à partir de » des sources sont utilisés tels quels. 164 Les loyers « à partir de » des sources sont utilisés tels quels.
165 </p> 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 <div className="tiles"> 172 <div className="tiles">
168 <div className="tile hero-tile"> 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 .pass-dot { animation: none; opacity: 0.7; } 926 .pass-dot { animation: none; opacity: 0.7; }
927 } 927 }
928 .pass-actions { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; margin-bottom: 14px; } 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 return {"type": "FeatureCollection", "features": features} 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 @app.get("/api/listings/{uid}") 193 @app.get("/api/listings/{uid}")
171 def get_listing(uid: str): 194 def get_listing(uid: str):
172 con = db.connect() 195 con = db.connect()
modified requirements.txt +3 −0
@@ -4,3 +4,6 @@ fastapi>=0.110
4 uvicorn>=0.29 4 uvicorn>=0.29
5 requests>=2.31 5 requests>=2.31
6 beautifulsoup4>=4.12 6 beautifulsoup4>=4.12
7+reportlab>=4.0
8+pillow>=10.0
9+qrcode>=7.4
7 10
Menu

Theme