spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# pdfgen.py : génération PDF au style « éditorial sharp » de Lou-Ka5# - 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, histogramme8# 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 en11# page — aucune dépendance de police externe.12# -----------------------------------------------------------------------------13from __future__ import annotations1415import io16import json17import math18import time1920import qrcode21import requests22from reportlab.lib.colors import HexColor23from reportlab.lib.pagesizes import letter24from reportlab.lib.utils import ImageReader25from reportlab.pdfgen import canvas as rl_canvas2627from . import db2829# --- palette Lou-Ka ---------------------------------------------------------30PAPER = HexColor("#f5f3ee")31SURFACE = HexColor("#ffffff")32INK = HexColor("#141814")33INK2 = HexColor("#4d5551")34INK3 = HexColor("#8b928c")35GREEN = HexColor("#1c5c41")36GREEN_DEEP = HexColor("#123f2e")37LIME = HexColor("#d9f26b")38LIME_SOFT = HexColor("#f0f9d2")39AMBER = HexColor("#e8a33d")40AMBER_SOFT = HexColor("#fdf3e2")4142PAGE_W, PAGE_H = letter43M = 40 # marge44IMG_TIMEOUT = 645UA = "LouKaBot/1.0 (+https://www.lou-ka.com/bot; contact@spboucher.ai)"4647POI_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}53PROX_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]5859NBSP = " "606162def _fmt_money(v) -> str:63 return f"{v:,.0f}".replace(",", f"{NBSP}") + f"{NBSP}$" if v is not None else "—"646566def _fmt_dist(m_: float) -> str:67 return (f"{round(m_ / 10) * 10}{NBSP}m" if m_ < 100068 else f"{m_ / 1000:.1f}".replace(".", ",") + f"{NBSP}km")697071def _marche(m_: float) -> str:72 return f"≈{NBSP}{max(1, round(m_ * 1.3 / 80))}{NBSP}min à pied"737475def _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 None828384class _Style:85 """Petits composants du style « éditorial sharp » sur un canvas."""8687 def __init__(self, c: rl_canvas.Canvas):88 self.c = c8990 def fond(self):91 self.c.setFillColor(PAPER)92 self.c.rect(0, 0, PAGE_W, PAGE_H, stroke=0, fill=1)9394 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.c97 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) + 8102 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 + bw111112 def entete(self, titre: str):113 c = self.c114 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 contenu125126 def pied(self, texte: str, page: int | None = None):127 c = self.c128 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}")137138 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.c142 w = c.stringWidth(texte, "Helvetica-Bold", taille) + 14143 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 + 6153154 def titre_section(self, y: float, texte: str) -> float:155 c = self.c156 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 - 18164165 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.c170 c.setFont("Helvetica", taille)171 c.setFillColor(couleur)172 mots = texte.split()173 ligne = ""174 n = 0175 for mot in mots:176 essai = (ligne + " " + mot).strip()177 if c.stringWidth(essai, "Helvetica", taille) <= largeur:178 ligne = essai179 else:180 c.drawString(x, y, ligne)181 y -= interligne182 n += 1183 ligne = mot184 if n >= max_lignes - 1:185 ligne += " …"186 break187 if ligne:188 c.drawString(x, y, ligne)189 y -= interligne190 return y191192193# ---------------------------------------------------------------------------194# Fiche de propriété195# ---------------------------------------------------------------------------196197def 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 None204 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 None209 poi = []210 quartier = None211 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 qmod216 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()222223 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")229230 # --- 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 None238 bx = M + c.stringWidth(prix_txt, "Helvetica-Bold", 30) + 14239 if l.get("price") and loyer_secteur:240 delta = (l["price"] - loyer_secteur) / loyer_secteur241 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 -= 44250251 # --- titre + adresse252 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 -= 14256 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 -= 20262263 # --- chips clés264 x = M265 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 -= 30285286 # --- photos : 1 grande + 3 vignettes287 imgs = [im for im in (_fetch_image(u) for u in l["images"][:4]) if im]288 if imgs:289 big_h = 170290 big_w = big_h * 16 / 10.5291 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 pass298 c.rect(M, y - big_h, big_w, big_h, stroke=1, fill=0)299 tx = M + big_w + 10300 th = (big_h - 16) / 3301 for i, im in enumerate(imgs[1:4]):302 ty = y - (i + 1) * th - i * 8303 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 pass309 y -= big_h + 18310311 # --- « En bref » (digest) sur bandeau lime312 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 -= 54322323 # --- description (2-3 sections max) | inclusions en colonne droite324 col_g = PAGE_W * 0.56 - M325 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 -= 12332 y_desc = s.paragraphe(M, y_desc, sec["texte"], col_g, max_lignes=6)333 y_desc -= 4334 if y_desc < 150:335 break336 elif l.get("description"):337 y_desc = s.paragraphe(M, y_desc, l["description"], col_g, max_lignes=16)338339 xr = M + col_g + 24340 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 -= 12369 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 -= 11375376 y = min(y_desc, y_incl) - 8377378 # --- 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")384385 # Le quartier386 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) / 3396 for i, (lab, val) in enumerate(tuiles):397 tx = M + (i % 3) * (tw + 10)398 ty = y - 34 - (i // 3) * 44399 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 + 6410411 prox = quartier.get("proximite") or {}412 for k, lab in PROX_LABELS:413 if k not in prox:414 continue415 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 - 160420 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 -= 14430 y -= 4431 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 = M449 for txt, fg, bg, stk in badges_q:450 x = s.pilule(x, y - 8, txt, fg, bg, stk)451 y -= 30452453 # À proximité454 if poi:455 y = s.titre_section(y, "À proximité")456 col2 = y457 for i, p in enumerate(poi[:12]):458 px = M if i % 2 == 0 else M + (PAGE_W - 2 * M) / 2 + 10459 if i % 2 == 0 and i > 0:460 y -= 13461 py = y462 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 -= 26472473 # Références : QR + gestionnaire + source474 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}")491492 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()497498499def 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 - 18507508509# ---------------------------------------------------------------------------510# Rapport global du marché511# ---------------------------------------------------------------------------512513_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]525526527def _region_de(city: str) -> str:528 for nom, villes in _REGIONS:529 if city in villes:530 return nom531 return "Grand Montréal & environs"532533534def rapport_pdf() -> bytes:535 """Rapport global du marché locatif Lou-Ka (multi-pages).536537 Tous les chiffres viennent de marketstats.compute() — la même source538 que la page Statistiques du site.539 """540 from . import marketstats541 st = marketstats.compute()542 reg_file = json.loads((db.DB_PATH.parent / "sources.json").read_text("utf-8"))["sources"]543 noms = {s["id"]: s["name"] for s in reg_file}544545 t = st["totals"]546 total, moy, med, gps = t["total"], t["avg"] or 0, t["median"] or 0, t["gps_pct"]547 n_sources = t["sources"]548549 buf = io.BytesIO()550 c = rl_canvas.Canvas(buf, pagesize=letter)551 c.setTitle("Lou-Ka — Rapport du marché locatif")552 s = _Style(c)553554 # ---- page 1 : couverture + grands chiffres + régions555 s.fond()556 s.logo(M, PAGE_H - 130, 42)557 c.setFont("Helvetica-Bold", 24)558 c.setFillColor(INK)559 c.drawString(M, PAGE_H - 185, "Rapport du marché locatif")560 c.setFont("Courier-Bold", 9)561 c.setFillColor(INK3)562 c.drawString(M, PAGE_H - 205,563 time.strftime("PROVINCE DE QUÉBEC · GÉNÉRÉ LE %Y-%m-%d · WWW.LOU-KA.COM"))564565 grands = [(f"{total:,}".replace(",", NBSP), "annonces actives"),566 (str(n_sources), "gestionnaires connectés"),567 (_fmt_money(moy), "loyer moyen"),568 (_fmt_money(med), "loyer médian"),569 (f"{gps}{NBSP}%", "annonces géolocalisées")]570 tw = (PAGE_W - 2 * M - 4 * 10) / 5571 for i, (val, lab) in enumerate(grands):572 tx = M + i * (tw + 10)573 ty = PAGE_H - 285574 c.setFillColor(INK)575 c.roundRect(tx + 3, ty - 3, tw, 58, 6, stroke=0, fill=1) # ombre décalée576 c.setFillColor(SURFACE)577 c.setStrokeColor(INK)578 c.setLineWidth(1.5)579 c.roundRect(tx, ty, tw, 58, 6, stroke=1, fill=1)580 c.setFont("Helvetica-Bold", 15)581 c.setFillColor(INK)582 c.drawCentredString(tx + tw / 2, ty + 32, val)583 c.setFont("Courier", 6.3)584 c.setFillColor(INK3)585 c.drawCentredString(tx + tw / 2, ty + 14, lab.upper())586587 y = s.titre_section(PAGE_H - 330, "Couverture par région")588 c.setFont("Courier-Bold", 7)589 c.setFillColor(INK3)590 for lab, xoff in (("RÉGION", 0), ("ANNONCES", 280), ("SOURCES", 360),591 ("LOYER MOYEN", 430)):592 c.drawString(M + xoff, y, lab)593 y -= 4594 c.setStrokeColor(INK)595 c.setLineWidth(1)596 c.line(M, y, PAGE_W - M, y)597 y -= 14598 regions = st["by_region"]599 max_n = max((r["count"] for r in regions), default=1)600 for r in regions:601 c.setFont("Helvetica-Bold", 8.5)602 c.setFillColor(INK)603 c.drawString(M, y, r["key"])604 c.setFillColor(LIME)605 c.setStrokeColor(INK)606 c.setLineWidth(0.7)607 bw = 90 * r["count"] / max_n608 c.roundRect(M + 180, y - 1, max(3, bw), 8, 3, stroke=1, fill=1)609 c.setFont("Helvetica", 8.5)610 c.setFillColor(INK2)611 c.drawRightString(M + 330, y, f"{r['count']:,}".replace(",", NBSP))612 c.drawRightString(M + 400, y, str(r["sources"]))613 c.drawRightString(M + 500, y, _fmt_money(r["avg_price"]) if r["avg_price"] else "—")614 y -= 15615 s.pied("Rapport généré automatiquement à partir des annonces publiques "616 "agrégées par Lou-Ka. Loyers : bornes 300–10 000 $.", 1)617 c.showPage()618619 # ---- page 2 : histogramme des loyers + par type620 s.fond()621 y = s.entete("Rapport du marché · loyers")622 y = s.titre_section(y, "Distribution des loyers")623 hist = st["histogram"]624 max_c = max((h["count"] for h in hist), default=1) or 1625 ch_h, ch_y = 150, y - 170626 bw = (PAGE_W - 2 * M) / len(hist)627 for i, h in enumerate(hist):628 bh = ch_h * h["count"] / max_c629 bx = M + i * bw630 borne = h["hi"] is None or h["lo"] == 0631 c.setFillColor(INK3 if borne else GREEN)632 c.setStrokeColor(INK)633 c.setLineWidth(0.6)634 c.rect(bx + 2, ch_y, bw - 4, max(1, bh), stroke=1, fill=1)635 if h["count"] and h["count"] > max_c * 0.06:636 c.setFont("Courier-Bold", 6)637 c.setFillColor(INK)638 c.drawCentredString(bx + bw / 2, ch_y + bh + 3, str(h["count"]))639 c.setFont("Helvetica", 5.6)640 c.setFillColor(INK3)641 lab = f"<{h['hi']}" if h["lo"] == 0 else (f"{h['lo']}+" if h["hi"] is None642 else str(h["lo"]))643 c.drawCentredString(bx + bw / 2, ch_y - 9, lab)644 y = ch_y - 30645646 y = s.titre_section(y, "Par taille de logement")647 c.setFont("Courier-Bold", 7)648 c.setFillColor(INK3)649 for lab, xoff in (("TAILLE", 0), ("ANNONCES", 160), ("LOYER MOYEN", 260),650 ("LOYER MIN", 360)):651 c.drawString(M + xoff, y, lab)652 y -= 4653 c.line(M, y, PAGE_W - M, y)654 y -= 13655 for r in st["by_type"][:9]:656 c.setFont("Helvetica-Bold", 8.5)657 c.setFillColor(INK)658 c.drawString(M, y, r["key"])659 c.setFont("Helvetica", 8.5)660 c.setFillColor(INK2)661 c.drawRightString(M + 220, y, str(r["count"]))662 c.drawRightString(M + 330, y, _fmt_money(r["avg_price"]) if r["avg_price"] else "—")663 c.drawRightString(M + 430, y, _fmt_money(r["min_price"]) if r["min_price"] else "—")664 y -= 13665 s.pied("Lou-Ka — agrégateur indépendant. Chaque annonce renvoie à la "666 "source originale du gestionnaire.", 2)667 c.showPage()668669 # ---- page 3 : offre, inclusions, prix au pi², baisses de prix670 s.fond()671 y = s.entete("Rapport du marché · offre")672 o = st["offre"]673 y = s.titre_section(y, "Inclusions et caractéristiques du parc")674 carac = [("Chauffage inclus", o["chauffage_pct"]),675 ("Électricité incluse", o["electricite_pct"]),676 ("Eau chaude incluse", o["eau_chaude_pct"]),677 ("Internet inclus", o["internet_pct"]),678 ("Climatisation", o["clim_pct"]),679 ("Stationnement", o["stationnement_pct"]),680 ("Balcon", o["balcon_pct"]),681 ("Meublé", o["furnished_pct"])]682 for lab, v in carac:683 if v is None:684 continue685 c.setFont("Helvetica", 8.5)686 c.setFillColor(INK2)687 c.drawString(M, y, lab)688 bx0, bw_ = M + 150, PAGE_W - 2 * M - 200689 c.setFillColor(SURFACE)690 c.setStrokeColor(INK)691 c.setLineWidth(0.8)692 c.roundRect(bx0, y - 1, bw_, 8, 4, stroke=1, fill=1)693 c.setFillColor(GREEN)694 c.roundRect(bx0, y - 1, bw_ * min(1, v / 100), 8, 4, stroke=0, fill=1)695 c.setFont("Courier-Bold", 8)696 c.setFillColor(INK)697 c.drawRightString(PAGE_W - M, y, f"{v}{NBSP}%")698 y -= 15699 y -= 6700 c.setFont("Helvetica", 8)701 c.setFillColor(INK3)702 c.drawString(M, y, f"Disponibles maintenant : {o['dispo_now']:,} · à date future : "703 f"{o['dispo_date']:,} · superficie moyenne : "704 f"{o['superficie_moyenne'] or '—'} pi² "705 f"({o['superficie_connue']:,} annonces la publient)"706 .replace(",", NBSP))707 y -= 24708709 if o["prix_pi2"]:710 y = s.titre_section(y, "Prix au pied carré (loyer / superficie)")711 for r in o["prix_pi2"][:6]:712 c.setFont("Helvetica-Bold", 8.5)713 c.setFillColor(INK)714 c.drawString(M, y, r["key"])715 c.setFont("Helvetica", 8.5)716 c.setFillColor(INK2)717 c.drawString(M + 70, y, f"{r['val']:.2f}".replace(".", ",") +718 f"{NBSP}$/pi² · {r['count']} annonces")719 y -= 13720 y -= 10721722 if st["baisses"]:723 y = s.titre_section(y, "Baisses de prix récentes (30 jours)")724 for b in st["baisses"][:8]:725 c.setFont("Helvetica", 8.5)726 c.setFillColor(INK2)727 c.drawString(M, y, f"{(b['title'] or b['uid'])[:46]} — {b['city'] or ''}")728 c.setFont("Courier-Bold", 8)729 c.setFillColor(GREEN)730 c.drawRightString(PAGE_W - M, y,731 f"{_fmt_money(b['avant'])} → {_fmt_money(b['apres'])} "732 f"({b['pct']}{NBSP}%)".replace(".", ","))733 y -= 13734 s.pied("Caractéristiques dérivées des annonces publiées ; les inclusions "735 "non mentionnées par une source ne sont pas comptées.", 3)736 c.showPage()737738 # ---- page 4 : top villes + top gestionnaires739 s.fond()740 y = s.entete("Rapport du marché · détail")741 y = s.titre_section(y, "Top 20 des villes")742 col_w = (PAGE_W - 2 * M) / 2743 for i, r in enumerate(st["by_city"][:20]):744 vx = M if i < 10 else M + col_w745 vy = y - (i % 10) * 13746 c.setFont("Helvetica", 8.5)747 c.setFillColor(INK2)748 c.drawString(vx, vy, f"{i + 1:>2}. {r['key']}")749 c.setFont("Courier-Bold", 8)750 c.setFillColor(INK)751 c.drawRightString(vx + col_w - 24, vy, f"{r['count']:,}".replace(",", NBSP))752 y -= 10 * 13 + 16753754 y = s.titre_section(y, "Top 20 des gestionnaires")755 for i, r in enumerate(st["by_source"][:20]):756 vx = M if i < 10 else M + col_w757 vy = y - (i % 10) * 13758 c.setFont("Helvetica", 8.5)759 c.setFillColor(INK2)760 c.drawString(vx, vy, f"{i + 1:>2}. {noms.get(r['key'], r['key'])[:34]}")761 c.setFont("Courier-Bold", 8)762 c.setFillColor(INK)763 c.drawRightString(vx + col_w - 24, vy, f"{r['count']:,}".replace(",", NBSP))764 y -= 10 * 13 + 20765766 c.setFont("Helvetica", 7.5)767 c.setFillColor(INK3)768 c.drawString(M, y, "Sources de données de quartier : Statistique Canada (Recensement 2021, "769 "licence ouverte), INSPQ (CC-BY 4.0), Ville de Montréal (CC-BY 4.0), OpenStreetMap.")770 s.pied("© Lou-Ka — www.lou-ka.com · rapport non contractuel, généré automatiquement.", 4)771 c.save()772 return buf.getvalue()773