Python 68.8%
TypeScript 18.6%
CSS 8.7%
JavaScript 3.3%
HTML 0.6%
1# -----------------------------------------------------------------------------2# Rent-Ka — Rental listings aggregator (Canada, outside Québec)3# Author: Simon-Pierre Boucher — contact@spboucher.ai4# pdfgen.py : génération PDF à l'identité visuelle officielle Rent-Ka5# - fiche_pdf(uid) : fiche de propriété complète — tout ce que montre la6# fiche web : photos, prix + analyse Rent-Ka (juste valeur, histogramme),7# chips, description structurée, inclusions, coût réel mensuel,8# historique Rent-Ka (+ vies antérieures), passeport de l'immeuble,9# gestionnaire (fiche Google + avis synchronisés), registre des loyers,10# historique TAL, inondation, qualité de l'air, coût d'électricité,11# commerces/transport, essence, KA Scores, score hiver, quartier,12# à proximité, QR13# - rapport_pdf() : rapport global du marché (totaux, régions, histogramme14# des loyers, types, villes, gestionnaires)15# Palette identique au site : blanc #ffffff, marine #0b1330, orange #ff6a00,16# bleu vif #2258ff, gris pâle #f2f2f2. Polices de base (Helvetica) stylées17# par la mise en page — aucune dépendance de police externe.18# -----------------------------------------------------------------------------19from __future__ import annotations2021import io22import json23import math24import time2526import qrcode27import requests28from reportlab.lib.colors import HexColor29from reportlab.lib.pagesizes import letter30from reportlab.lib.utils import ImageReader31from reportlab.pdfgen import canvas as rl_canvas3233from . import db3435# --- palette Rent-Ka (identité officielle) ------------------------------------36PAPER = HexColor("#ffffff")37SURFACE = HexColor("#ffffff")38INK = HexColor("#0b1330") # bleu marine profond39INK2 = HexColor("#3e4560")40INK3 = HexColor("#8a90a5")41GREEN = HexColor("#2258ff") # accent secondaire : bleu vif42GREEN_DEEP = HexColor("#0b1330")43LIME = HexColor("#ff6a00") # accent d'action : orange vif44LIME_SOFT = HexColor("#fff1e6")45AMBER = HexColor("#ff6a00")46AMBER_SOFT = HexColor("#fff1e6")4748PAGE_W, PAGE_H = letter49M = 40 # marge50IMG_TIMEOUT = 651UA = "RentKaBot/1.0 (+https://www.rent-ka.com/bot; contact@spboucher.ai)"5253POI_LABELS = {54 "epicerie": "Épicerie", "depanneur": "Dépanneur", "pharmacie": "Pharmacie",55 "ecole": "École", "garderie": "Garderie", "parc": "Parc", "bus": "Arrêt de bus",56 "metro": "Métro", "gym": "Gym", "cafe": "Café", "clinique": "Clinique",57 "hopital": "Hôpital", "bibliotheque": "Bibliothèque",58}59PROX_LABELS = [60 ("prox_epicerie", "Épiceries"), ("prox_transport", "Transport en commun"),61 ("prox_parc", "Parcs"), ("prox_ecole_prim", "Écoles primaires"),62 ("prox_sante", "Soins de santé"), ("prox_pharmacie", "Pharmacies"),63]6465NBSP = " "666768def _fmt_money(v) -> str:69 return f"{v:,.0f}".replace(",", f"{NBSP}") + f"{NBSP}$" if v is not None else "—"707172def _fmt_dist(m_: float) -> str:73 return (f"{round(m_ / 10) * 10}{NBSP}m" if m_ < 100074 else f"{m_ / 1000:.1f}".replace(".", ",") + f"{NBSP}km")757677def _marche(m_: float) -> str:78 return f"≈{NBSP}{max(1, round(m_ * 1.3 / 80))}{NBSP}min à pied"798081def _fetch_image(url: str) -> ImageReader | None:82 try:83 r = requests.get(url, timeout=IMG_TIMEOUT, headers={"User-Agent": UA})84 r.raise_for_status()85 raw = r.content86 try:87 # les photos source dépassent parfois 5 Mo chacune : sans88 # redimensionnement le PDF grimpe à 30 Mo89 from PIL import Image90 im = Image.open(io.BytesIO(raw))91 im.thumbnail((1200, 1200))92 buf = io.BytesIO()93 im.convert("RGB").save(buf, "JPEG", quality=82)94 buf.seek(0)95 return ImageReader(buf)96 except Exception:97 return ImageReader(io.BytesIO(raw))98 except Exception:99 return None100101102class _Style:103 """Petits composants du style « éditorial sharp » sur un canvas."""104105 def __init__(self, c: rl_canvas.Canvas):106 self.c = c107108 def fond(self):109 self.c.setFillColor(PAPER)110 self.c.rect(0, 0, PAGE_W, PAGE_H, stroke=0, fill=1)111112 def logo(self, x: float, y: float, taille: float = 22):113 """Wordmark Rent·Ka : « Lou » encre + boîte encre avec « Ka » lime."""114 c = self.c115 c.setFont("Helvetica-Bold", taille)116 c.setFillColor(INK)117 c.drawString(x, y, "Lou")118 w = c.stringWidth("Lou", "Helvetica-Bold", taille)119 bw = c.stringWidth("Ka", "Helvetica-Bold", taille) + 8120 c.saveState()121 c.translate(x + w + 3 + bw / 2, y + taille * 0.32)122 c.rotate(-3)123 c.setFillColor(INK)124 c.roundRect(-bw / 2, -taille * 0.62, bw, taille * 1.15, 4, stroke=0, fill=1)125 c.setFillColor(LIME)126 c.drawCentredString(0, -taille * 0.30, "Ka")127 c.restoreState()128 return x + w + 6 + bw129130 def entete(self, titre: str):131 c = self.c132 self.logo(M, PAGE_H - M - 16)133 c.setFont("Courier-Bold", 8)134 c.setFillColor(INK3)135 c.drawRightString(PAGE_W - M, PAGE_H - M - 6,136 titre.upper())137 c.drawRightString(PAGE_W - M, PAGE_H - M - 16,138 time.strftime("GÉNÉRÉ LE %Y-%m-%d · WWW.LOU-KA.COM"))139 c.setStrokeColor(INK)140 c.setLineWidth(2)141 c.line(M, PAGE_H - M - 26, PAGE_W - M, PAGE_H - M - 26)142 return PAGE_H - M - 40 # y de départ du contenu143144 def pied(self, texte: str, page: int | None = None):145 c = self.c146 c.setStrokeColor(INK)147 c.setLineWidth(1)148 c.line(M, M + 16, PAGE_W - M, M + 16)149 c.setFont("Helvetica", 6.5)150 c.setFillColor(INK3)151 c.drawString(M, M + 6, texte[:150])152 if page:153 c.setFont("Courier-Bold", 7)154 c.drawRightString(PAGE_W - M, M + 6, f"P.{page}")155156 def pilule(self, x: float, y: float, texte: str, fg=LIME, bg=INK,157 stroke=None, taille: float = 8) -> float:158 """Chip arrondie ; retourne le x suivant."""159 c = self.c160 w = c.stringWidth(texte, "Helvetica-Bold", taille) + 14161 c.setFillColor(bg)162 if stroke:163 c.setStrokeColor(stroke)164 c.setLineWidth(1.2)165 c.roundRect(x, y, w, taille + 9, (taille + 9) / 2,166 stroke=1 if stroke else 0, fill=1)167 c.setFillColor(fg)168 c.setFont("Helvetica-Bold", taille)169 c.drawString(x + 7, y + 5, texte)170 return x + w + 6171172 def titre_section(self, y: float, texte: str) -> float:173 c = self.c174 c.setFont("Helvetica-Bold", 12.5)175 c.setFillColor(INK)176 c.drawString(M, y, texte)177 c.setStrokeColor(LIME)178 c.setLineWidth(3)179 w = c.stringWidth(texte, "Helvetica-Bold", 12.5)180 c.line(M, y - 4, M + w, y - 4)181 return y - 18182183 def paragraphe(self, x: float, y: float, texte: str, largeur: float,184 taille: float = 8.5, couleur=INK2, interligne: float = 11.5,185 max_lignes: int = 100) -> float:186 """Texte multi-lignes avec découpe aux mots ; retourne le y suivant."""187 c = self.c188 c.setFont("Helvetica", taille)189 c.setFillColor(couleur)190 mots = texte.split()191 ligne = ""192 n = 0193 for mot in mots:194 essai = (ligne + " " + mot).strip()195 if c.stringWidth(essai, "Helvetica", taille) <= largeur:196 ligne = essai197 else:198 c.drawString(x, y, ligne)199 y -= interligne200 n += 1201 ligne = mot202 if n >= max_lignes - 1:203 ligne += " …"204 break205 if ligne:206 c.drawString(x, y, ligne)207 y -= interligne208 return y209210 def tuile(self, x: float, y: float, w: float, h: float,211 val: str, lab: str):212 """Tuile KPI blanche à ombre décalée (x, y = coin inférieur gauche)."""213 c = self.c214 c.setFillColor(INK)215 c.roundRect(x + 2.5, y - 2.5, w, h, 5, stroke=0, fill=1)216 c.setFillColor(SURFACE)217 c.setStrokeColor(INK)218 c.setLineWidth(1.2)219 c.roundRect(x, y, w, h, 5, stroke=1, fill=1)220 c.setFont("Helvetica-Bold", 12)221 c.setFillColor(INK)222 c.drawString(x + 8, y + h - 20, str(val))223 c.setFont("Courier", 6.2)224 c.setFillColor(INK3)225 c.drawString(x + 8, y + 7, lab.upper()[: max(4, int(w / 4.2))])226227228# ---------------------------------------------------------------------------229# Fiche de propriété230# ---------------------------------------------------------------------------231232_MOIS = ["janvier", "février", "mars", "avril", "mai", "juin", "juillet",233 "août", "septembre", "octobre", "novembre", "décembre"]234235236def _fmt_mois(d: str | None) -> str:237 """« 2017-11-04 » → « novembre 2017 » (sinon la valeur telle quelle)."""238 if not d:239 return "—"240 try:241 an, mois = d[:7].split("-")242 return f"{_MOIS[int(mois) - 1]} {an}"243 except Exception:244 return d245246247def _fmt_ts(ts, jour: bool = False) -> str:248 """Timestamp unix → « 6 août 2026 » (jour=True) ou « août 2026 »."""249 if not ts:250 return "—"251 t = time.localtime(ts)252 mois = f"{_MOIS[t.tm_mon - 1]} {t.tm_year}"253 return f"{t.tm_mday} {mois}" if jour else mois254255256_VERDICTS = {257 "sous": ("Sous le marché", GREEN_DEEP, LIME_SOFT, GREEN),258 "marche": ("Dans le marché", INK2, SURFACE, INK),259 "sur": ("Au-dessus du marché", HexColor("#8a5a12"), AMBER_SOFT, AMBER),260}261262_PIED_FICHE = ("Données de l'annonce publiée par le gestionnaire — Rent-Ka est "263 "un agrégateur indépendant. Enrichissements (analyse de prix, "264 "registre, TAL, environnement) fournis à titre indicatif, sans "265 "valeur juridique.")266267def fiche_pdf(uid: str) -> bytes | None:268 """PDF de la fiche du logement `uid` — reflète TOUS les blocs de la fiche269 web : prix + analyse Rent-Ka, photos, description, inclusions, registre des270 loyers, historique TAL, risque d'inondation, qualité de l'air, coût271 d'électricité, commerces/transport, essence, KA Scores, quartier,272 à proximité, références + QR. None si introuvable."""273 con = db.connect()274 row = con.execute("SELECT * FROM listings WHERE uid=?", (uid,)).fetchone()275 if row is None:276 con.close()277 return None278 l = dict(row)279 l["amenities"] = json.loads(l.get("amenities") or "[]")280 l["images"] = json.loads(l.get("images") or "[]")281 l["details"] = json.loads(l.get("details") or "{}")282 l["digest"] = json.loads(l["digest"]) if l.get("digest") else None283 poi = []284 quartier = None285 if l.get("lat") is not None:286 key = f"{round(l['lat'], 4)},{round(l['lng'], 4)}"287 pr = con.execute("SELECT pois FROM poi_cache WHERE coord_key=?", (key,)).fetchone()288 poi = json.loads(pr["pois"]) if pr else []289 reg = json.loads((db.DB_PATH.parent / "sources.json").read_text("utf-8"))["sources"]290 src_name = next((s["name"] for s in reg if s["id"] == l["source"]), l["source"])291 kasc = None292 if l.get("coord_key"):293 kr = con.execute("SELECT walk, transit, bike, calme, services,"294 " global AS glob FROM kascores WHERE coord_key=?",295 (l["coord_key"],)).fetchone()296 if kr is not None:297 kasc = dict(kr)298 price_history = [dict(r) for r in con.execute(299 "SELECT ts, price FROM price_log WHERE uid=? ORDER BY ts DESC LIMIT 6",300 (uid,)).fetchall()]301 con.close()302303 # --- enrichissements de la fiche (mêmes modules que les blocs du site) ---304 lat, lng = l.get("lat"), l.get("lng")305 geo = lat is not None and lng is not None306307 def _safe(fn, *a, **k):308 try:309 return fn(*a, **k)310 except Exception:311 return None312313 from . import fairvalue as fv_mod314 fv = _safe(fv_mod.explain, uid)315 # Québec-only layers removed in Rent-Ka (Hydro-Québec, rent registry,316 # TAL, flood zones, air, gas, quartier, real-cost): their PDF sections317 # are simply skipped (guards below never fire)318 rdl_d = tal_d = inon = air_d = gaz_d = com = hyd = cout = None319 # rental intelligence dossier (same modules as the site blocks)320 from . import building as bld_mod321 from . import history as hist_mod322 from . import hiver as hiv_mod323 from . import managers as mgr_mod324 from . import recycled as rec_mod325 histo = _safe(hist_mod.timeline, uid)326 recyc = _safe(rec_mod.lookup, uid)327 imm = (_safe(bld_mod.fiche, l["building_key"])328 if l.get("building_key") else None)329 hiv = _safe(hiv_mod.score, lat, lng) if geo else None330 gest = _safe(mgr_mod.fiche, l["source"])331332 buf = io.BytesIO()333 c = rl_canvas.Canvas(buf, pagesize=letter)334 c.setTitle(f"Rent-Ka — {l.get('title') or l.get('address')}")335 s = _Style(c)336 s.fond()337 y = s.entete("Fiche logement")338 page = [1]339340 def saut_page() -> float:341 s.pied(_PIED_FICHE, page[0])342 c.showPage()343 page[0] += 1344 s.fond()345 return s.entete("Fiche logement · suite")346347 def place(y_: float, besoin: float) -> float:348 """Saute de page si `besoin` points ne tiennent plus sous y_."""349 return saut_page() if (y_ - besoin) < M + 34 else y_350351 # --- bloc prix + badge marché (verdict du modèle Rent-Ka en priorité)352 prix_txt = (_fmt_money(l["price"]) + f"{NBSP}/ mois") if l.get("price") \353 else (l.get("price_label") or "Prix sur demande")354 c.setFont("Helvetica-Bold", 30)355 c.setFillColor(INK)356 c.drawString(M, y - 24, prix_txt)357 loyer_secteur = (quartier or {}).get("demographie", {}).get("loyer_moyen") \358 if quartier else None359 bx = M + c.stringWidth(prix_txt, "Helvetica-Bold", 30) + 14360 if l.get("price") and fv and fv.get("verdict") in _VERDICTS:361 dev = fv.get("deviation") or 0362 pct = f"{'+' if dev > 0 else '−'}{abs(round(dev * 100))}{NBSP}%"363 lab, fg, bg, stk = _VERDICTS[fv["verdict"]]364 s.pilule(bx, y - 22, f"{pct} vs juste valeur · {lab}", fg, bg, stk)365 elif l.get("price") and loyer_secteur:366 delta = (l["price"] - loyer_secteur) / loyer_secteur367 pct = f"{'+' if delta > 0 else '−'}{abs(round(delta * 100))}{NBSP}%"368 if delta <= -0.15:369 s.pilule(bx, y - 22, f"{pct} vs secteur · Bon deal", GREEN_DEEP, LIME_SOFT, GREEN)370 elif delta <= 0.10:371 s.pilule(bx, y - 22, f"{pct} vs secteur · Dans le marché", INK2, SURFACE, INK)372 else:373 s.pilule(bx, y - 22, f"{pct} vs secteur · Au-dessus du marché",374 HexColor("#8a5a12"), AMBER_SOFT, AMBER)375 y -= 44376377 # --- titre + adresse378 c.setFont("Helvetica-Bold", 13)379 c.setFillColor(INK)380 c.drawString(M, y, (l.get("title") or l.get("address") or "")[:80])381 y -= 14382 c.setFont("Helvetica", 9)383 c.setFillColor(INK2)384 loc = " · ".join(x for x in (l.get("address"), l.get("sector"), l.get("city"))385 if x and x != l.get("title"))386 c.drawString(M, y, loc[:110])387 y -= 20388389 # --- chips clés390 x = M391 chips = []392 if l.get("unit_type"):393 chips.append(l["unit_type"])394 ad = l.get("availability_date")395 if ad == "now":396 chips.append("Libre maintenant")397 elif ad:398 chips.append(f"Dispo {ad}")399 if l.get("area_sqft"):400 chips.append(f"{round(l['area_sqft'])}{NBSP}pi²")401 if l.get("furnished"):402 chips.append("Meublé")403 if l.get("pets"):404 chips.append({"oui": "Animaux acceptés", "non": "Animaux refusés",405 "conditions": "Animaux sous conditions"}.get(l["pets"], l["pets"]))406 if l["details"].get("floor") is not None:407 chips.append(f"{l['details']['floor']}e étage")408 for chip in chips[:7]:409 x = s.pilule(x, y - 6, chip)410 y -= 30411412 # --- photos : 1 grande + 3 vignettes413 imgs = [im for im in (_fetch_image(u) for u in l["images"][:4]) if im]414 if imgs:415 big_h = 170416 big_w = big_h * 16 / 10.5417 c.setStrokeColor(INK)418 c.setLineWidth(1.5)419 try:420 c.drawImage(imgs[0], M, y - big_h, big_w, big_h,421 preserveAspectRatio=True, anchor="c", mask="auto")422 except Exception:423 pass424 c.rect(M, y - big_h, big_w, big_h, stroke=1, fill=0)425 tx = M + big_w + 10426 th = (big_h - 16) / 3427 for i, im in enumerate(imgs[1:4]):428 ty = y - (i + 1) * th - i * 8429 try:430 c.drawImage(im, tx, ty, PAGE_W - M - tx, th,431 preserveAspectRatio=True, anchor="c", mask="auto")432 c.rect(tx, ty, PAGE_W - M - tx, th, stroke=1, fill=0)433 except Exception:434 pass435 y -= big_h + 18436437 # --- « En bref » (digest) sur bandeau lime438 dg = l.get("digest")439 if dg and dg.get("en_bref"):440 c.setFillColor(LIME_SOFT)441 c.setStrokeColor(GREEN)442 c.setLineWidth(1.2)443 c.roundRect(M, y - 40, PAGE_W - 2 * M, 44, 6, stroke=1, fill=1)444 s.paragraphe(M + 10, y - 8, "EN BREF — " + dg["en_bref"],445 PAGE_W - 2 * M - 20, taille=8.5, couleur=GREEN_DEEP,446 max_lignes=3)447 y -= 54448449 # --- description (2-3 sections max) | inclusions en colonne droite450 col_g = PAGE_W * 0.56 - M451 y_desc = s.titre_section(y, "Description")452 if dg and dg.get("sections"):453 for sec in dg["sections"][:3]:454 c.setFont("Helvetica-Bold", 9)455 c.setFillColor(INK)456 c.drawString(M, y_desc, sec["titre"][:60])457 y_desc -= 12458 y_desc = s.paragraphe(M, y_desc, sec["texte"], col_g, max_lignes=6)459 y_desc -= 4460 if y_desc < 150:461 break462 elif l.get("description"):463 y_desc = s.paragraphe(M, y_desc, l["description"], col_g, max_lignes=16)464465 xr = M + col_g + 24466 y_incl = s_titre_droite(c, y, xr, "Inclusions et commodités")467 d = l["details"]468 badges = []469 inc = d.get("inclusions") or {}470 for k, lab in (("heating", "Chauffage inclus"), ("electricity", "Électricité incluse"),471 ("hot_water", "Eau chaude incluse"), ("internet", "Internet inclus")):472 if inc.get(k):473 badges.append(lab)474 app = d.get("appliances") or {}475 if app.get("dishwasher"):476 badges.append("Lave-vaisselle")477 if app.get("washer_dryer"):478 badges.append("Laveuse-sécheuse")479 for k, lab in (("ac", "Climatisation"), ("elevator", "Ascenseur"),480 ("balcony", "Balcon"), ("pool", "Piscine"), ("gym", "Gym"),481 ("laundry", "Buanderie"), ("storage", "Rangement")):482 if d.get(k):483 badges.append(lab)484 if (d.get("parking") or {}).get("available"):485 badges.append("Stationnement")486 if l.get("furnished"):487 badges.append("Meublé")488 c.setFont("Helvetica", 8.5)489 for b in badges[:12]:490 c.setFillColor(GREEN)491 c.drawString(xr, y_incl, "✓")492 c.setFillColor(INK2)493 c.drawString(xr + 12, y_incl, b)494 y_incl -= 12495 autres = [a for a in l["amenities"]496 if not any(b.lower() in a.lower() or a.lower() in b.lower() for b in badges)]497 for a in autres[: max(0, 14 - len(badges))]:498 c.setFillColor(INK3)499 c.drawString(xr + 12, y_incl, a[:38])500 y_incl -= 11501502 y = min(y_desc, y_incl) - 16503504 # --- Analyse de prix Rent-Ka (juste valeur, fourchette, distribution)505 if fv and fv.get("fv"):506 hist = fv.get("histogram") or []507 y = place(y, 168 if hist else 92)508 y = s.titre_section(y, "Analyse de prix Rent-Ka")509 jv = _fmt_money(round(fv["fv"]))510 c.setFont("Helvetica-Bold", 17)511 c.setFillColor(INK)512 c.drawString(M, y - 10, jv)513 c.setFont("Helvetica", 8.5)514 c.setFillColor(INK2)515 c.drawString(M + c.stringWidth(jv, "Helvetica-Bold", 17) + 8, y - 10,516 "/ mois — juste valeur estimée par le modèle Rent-Ka")517 meta = []518 if fv.get("fv_low") and fv.get("fv_high"):519 meta.append(f"fourchette {_fmt_money(round(fv['fv_low']))} – "520 f"{_fmt_money(round(fv['fv_high']))}")521 if fv.get("confidence"):522 meta.append(f"confiance {fv['confidence']}")523 if fv.get("comps"):524 meta.append(f"{fv['comps']} comparables")525 if fv.get("segment_n"):526 meta.append(f"segment de {fv['segment_n']} annonces")527 c.setFont("Helvetica", 8)528 c.setFillColor(INK3)529 c.drawString(M, y - 24, " · ".join(meta)[:115])530 if l.get("price") and fv.get("verdict") in _VERDICTS:531 dev = fv.get("deviation") or 0532 pct = f"{'+' if dev > 0 else '−'}{abs(round(dev * 100))}{NBSP}%"533 lab, fg, bg, stk = _VERDICTS[fv["verdict"]]534 s.pilule(M, y - 48, f"{pct} vs juste valeur · {lab}", fg, bg, stk)535 y -= 58536 if hist:537 ch_h = 40538 lo, hi = hist[0]["x0"], hist[-1]["x1"]539 larg = PAGE_W - 2 * M540 base = y - ch_h - 6541 if hi > lo and fv.get("fv_low") and fv.get("fv_high"):542 bx0 = M + larg * max(0.0, (fv["fv_low"] - lo) / (hi - lo))543 bx1 = M + larg * min(1.0, (fv["fv_high"] - lo) / (hi - lo))544 c.setFillColor(LIME_SOFT)545 c.rect(bx0, base, max(2, bx1 - bx0), ch_h, stroke=0, fill=1)546 max_n = max((h["n"] for h in hist), default=1) or 1547 bw = larg / len(hist)548 c.setFillColor(GREEN)549 for i, h in enumerate(hist):550 bh = ch_h * h["n"] / max_n551 if bh > 0:552 c.rect(M + i * bw + 1, base, bw - 2, max(0.6, bh),553 stroke=0, fill=1)554 if hi > lo and l.get("price"):555 px = M + larg * (min(max(l["price"], lo), hi) - lo) / (hi - lo)556 c.setStrokeColor(LIME)557 c.setLineWidth(2)558 c.line(px, base - 3, px, base + ch_h + 3)559 c.setFont("Courier-Bold", 6.5)560 c.setFillColor(LIME)561 c.drawString(min(max(px - 28, M), PAGE_W - M - 92),562 base + ch_h + 6,563 f"CE LOYER · {_fmt_money(l['price'])}")564 c.setFont("Helvetica", 6.5)565 c.setFillColor(INK3)566 c.drawString(M, base - 9, _fmt_money(lo))567 c.drawRightString(PAGE_W - M, base - 9,568 _fmt_money(hi) + " — distribution des loyers du "569 "segment de référence")570 y = base - 20571 ph = [p for p in price_history if p.get("price")]572 if len(ph) >= 2 and ph[0]["price"] != ph[-1]["price"]:573 c.setFont("Helvetica", 8)574 c.setFillColor(INK2)575 chn = " → ".join(_fmt_money(p["price"]) for p in reversed(ph[:4]))576 c.drawString(M, y - 2, f"Historique de prix de l'annonce : {chn}")577 y -= 16578579 # --- Coût réel mensuel (loyer + frais, chaque poste étiqueté honnêtement)580 if cout and cout.get("loyer") is not None:581 lignes_cr = cout.get("lignes") or []582 y = place(y, 58 + len(lignes_cr) * 12 + 46)583 y = s.titre_section(y, "Coût réel mensuel")584 stat_cr = {"included": ("INCLUS", GREEN), "observed": ("OBSERVÉ", INK),585 "estimated": ("ESTIMÉ", LIME), "unknown": ("INCONNU", INK3)}586 for li in lignes_cr:587 lab, col = stat_cr.get(li.get("statut"), stat_cr["unknown"])588 c.setFont("Helvetica", 8.5)589 c.setFillColor(INK2)590 c.drawString(M, y, (li.get("poste") or "")[:46])591 c.setFont("Courier-Bold", 6.5)592 c.setFillColor(col)593 c.drawString(M + 230, y, lab)594 m_ = li.get("montant")595 if m_ is not None and m_ > 0:596 mtx = _fmt_money(m_)597 elif m_ == 0 and li.get("statut") == "included":598 mtx = f"0{NBSP}$"599 else:600 mtx = "—"601 c.setFont("Helvetica-Bold", 8.5)602 c.setFillColor(INK)603 c.drawRightString(PAGE_W - M, y, mtx)604 y -= 12605 if cout.get("total_estime") is not None:606 c.setStrokeColor(INK)607 c.setLineWidth(1.2)608 c.line(M, y + 8, PAGE_W - M, y + 8)609 c.setFont("Helvetica-Bold", 10)610 c.setFillColor(INK)611 c.drawString(M, y - 4, "Total estimé")612 tot = f"≈{NBSP}{_fmt_money(cout['total_estime'])} / mois"613 if cout.get("annuel_estime") is not None:614 tot += f" · ≈{NBSP}{_fmt_money(cout['annuel_estime'])} / an"615 c.drawRightString(PAGE_W - M, y - 4, tot)616 y -= 18617 if cout.get("postes_inconnus"):618 y = s.paragraphe(M, y - 2, "Postes non chiffrables avec les données "619 "publiées : "620 + ", ".join(cout["postes_inconnus"]).lower()621 + " — le total réel peut être plus élevé.",622 PAGE_W - 2 * M, taille=7.5,623 couleur=HexColor("#8a5a12"), interligne=9.5,624 max_lignes=3)625 pi2_d = cout.get("pi2")626 if pi2_d and pi2_d.get("valeur") is not None:627 v = f"{pi2_d['valeur']:.2f}".replace(".", ",")628 suite = f"{v}{NBSP}$/pi²"629 if pi2_d.get("percentile_secteur") is not None:630 suite += (f" — moins cher que {100 - pi2_d['percentile_secteur']}"631 f"{NBSP}% des {pi2_d.get('n_secteur')} comparables du "632 "secteur (~2 km)")633 elif pi2_d.get("percentile_ville") is not None:634 suite += (f" — moins cher que {100 - pi2_d['percentile_ville']}"635 f"{NBSP}% des {pi2_d.get('n_ville')} comparables "636 f"({pi2_d.get('portee_ville', '')})")637 c.setFont("Helvetica", 8)638 c.setFillColor(INK2)639 c.drawString(M, y - 2, suite[:120])640 y -= 12641 y -= 8642643 # --- Historique Rent-Ka (observations réelles des synchronisations)644 if histo:645 tl = (histo.get("timeline") or [])[:8]646 matches = ((recyc or {}).get("matches") or [])[:2]647 y = place(y, 46 + len(tl) * 11 + len(matches) * 22 + 18)648 y = s.titre_section(y, "Historique Rent-Ka")649 entete_h = (f"Suivie depuis {_fmt_ts(histo.get('premiere_observation'), jour=True)}"650 f" · {histo.get('jours_en_ligne', 0)} jour"651 f"{'s' if (histo.get('jours_en_ligne') or 0) > 1 else ''} en ligne"652 f" · {histo.get('modifications', 0)} modification"653 f"{'s' if (histo.get('modifications') or 0) > 1 else ''} observée"654 f"{'s' if (histo.get('modifications') or 0) > 1 else ''}")655 pi_, pa_ = histo.get("prix_initial"), histo.get("prix_actuel")656 if pi_ is not None and pa_ is not None and pi_ != pa_:657 var_ = histo.get("variation")658 entete_h += f" · {_fmt_money(pi_)} → {_fmt_money(pa_)}"659 if var_ is not None:660 entete_h += (f" ({'+' if var_ > 0 else '−'}"661 f"{abs(round(var_ * 100))}{NBSP}%)")662 c.setFont("Helvetica-Bold", 8.5)663 c.setFillColor(INK)664 c.drawString(M, y - 2, entete_h[:120])665 y -= 16666 ev_labels = {"description": "Description modifiée",667 "superficie": "Superficie modifiée",668 "dispo": "Disponibilité modifiée",669 "inclusions": "Inclusions modifiées",670 "photos": "Photos modifiées",671 "disparition": "Annonce retirée",672 "reapparition": "Annonce republiée"}673 if tl:674 for it in tl:675 c.setFont("Courier-Bold", 7)676 c.setFillColor(INK3)677 c.drawString(M, y, _fmt_ts(it.get("ts"), jour=True).upper())678 c.setFont("Helvetica", 8)679 c.setFillColor(INK2)680 if it.get("type") == "prix":681 if it.get("prix_avant") is not None:682 sens = ("baissé" if it["prix_avant"] > (it.get("prix") or 0)683 else "monté")684 txt_ev = (f"Prix {sens} de {_fmt_money(it['prix_avant'])} "685 f"à {_fmt_money(it.get('prix'))}")686 else:687 txt_ev = f"Premier prix observé : {_fmt_money(it.get('prix'))}"688 else:689 txt_ev = ev_labels.get(it.get("type"), it.get("type") or "")690 c.drawString(M + 78, y, txt_ev[:95])691 y -= 11692 else:693 c.setFont("Helvetica", 8)694 c.setFillColor(INK2)695 c.drawString(M, y, "Aucune modification observée depuis la première "696 "synchronisation de cette annonce.")697 y -= 11698 for m_ in matches:699 suite = f"Vie antérieure probable ({m_.get('confiance')}{NBSP}% de confiance)"700 if m_.get("prix") is not None:701 suite += f" — affiché {_fmt_money(m_['prix'])}"702 if m_.get("derniere_observation"):703 suite += f" jusqu'en {_fmt_ts(m_['derniere_observation'])}"704 c.setFont("Helvetica-Bold", 8)705 c.setFillColor(HexColor("#8a5a12"))706 c.drawString(M, y - 2, suite[:115])707 y -= 10708 c.setFont("Helvetica", 7)709 c.setFillColor(INK3)710 c.drawString(M, y - 2, ("signaux : " + " · ".join(m_.get("signaux") or []))[:125])711 y -= 12712 y -= 8713714 # --- Passeport de l'immeuble (ce que Rent-Ka observe du bâtiment)715 if imm and (imm.get("annonces_total") or 0) >= 2:716 y = place(y, 128)717 y = s.titre_section(y, "Passeport de l'immeuble")718 tw = (PAGE_W - 2 * M - 30) / 4719 rot = imm.get("rotation") or {}720 pres = imm.get("pression_loyers")721 tuiles_im = [722 (str(imm["annonces_total"]),723 f"annonces obs. ({imm.get('annonces_actives', 0)} actives)"),724 (f"≥{NBSP}{imm.get('unites_estimees', 0)}", "unités estimées"),725 (_fmt_money(imm.get("loyer_median")), "loyer médian (actives)"),726 (f"{imm['pi2_median']:.2f}".replace(".", ",") + f"{NBSP}$"727 if imm.get("pi2_median") is not None else "—", "médiane $/pi²"),728 ]729 for i, (val, lab) in enumerate(tuiles_im):730 s.tuile(M + i * (tw + 10), y - 44, tw, 38, val, lab)731 y -= 58732 par_cc = imm.get("loyer_median_par_cc") or {}733 if par_cc:734 parts = [f"{cc}{NBSP}ch. {_fmt_money(v)}"735 for cc, v in sorted(par_cc.items())]736 c.setFont("Helvetica", 8)737 c.setFillColor(INK2)738 c.drawString(M, y, ("Loyer médian par nombre de chambres : "739 + " · ".join(parts))[:118])740 y -= 13741 c.setFont("Helvetica-Bold", 8)742 c.setFillColor(INK)743 c.drawString(M, y, "Rotation des logements :")744 c.setFont("Helvetica", 8)745 c.setFillColor(INK2)746 if rot.get("statut") == "calculated":747 c.drawString(M + 118, y,748 (f"{rot.get('classe', '')} — {rot.get('annonces_12m')} "749 f"annonces sur 12 mois pour ≥{NBSP}"750 f"{rot.get('unites_estimees')} unités")[:100])751 else:752 c.drawString(M + 118, y, "données insuffisantes")753 y -= 12754 c.setFont("Helvetica-Bold", 8)755 c.setFillColor(INK)756 c.drawString(M, y, "Pression sur les loyers :")757 c.setFont("Helvetica", 8)758 c.setFillColor(INK2)759 if pres:760 v12 = pres.get("variation_12m") or 0761 c.drawString(M + 118, y,762 (f"{'+' if v12 > 0 else '−'}{abs(round(v12 * 100))}"763 f"{NBSP}% sur 12 mois — médiane des prix d'entrée "764 f"{_fmt_money(pres.get('mediane_12_24m'))} → "765 f"{_fmt_money(pres.get('mediane_12m'))} "766 f"({pres.get('n_12_24m')} vs {pres.get('n_12m')} annonces)")[:110])767 else:768 c.drawString(M + 118, y, "échantillon insuffisant")769 y -= 12770 c.setFont("Helvetica", 6.5)771 c.setFillColor(INK3)772 c.drawString(M, y - 2, (f"Immeuble suivi par Rent-Ka depuis "773 f"{_fmt_ts(imm.get('premiere_observation'))} — "774 "indicateurs calculés uniquement sur les annonces "775 "observées (pas un recensement du bâtiment).")[:135])776 y -= 16777778 # --- Qui gère ce logement? (fiche Google associée + avis synchronisés)779 g_ = (gest or {}).get("google_maps")780 if gest and g_ and g_.get("statut") != "non_associe":781 avis_g = gest.get("avis") or {}782 dist_g = avis_g.get("distribution") or {}783 tot_g = sum(dist_g.values()) if dist_g else 0784 y = place(y, 108 + (5 * 11 if tot_g else 0))785 y = s.titre_section(y, "Qui gère ce logement?")786 c.setFont("Helvetica-Bold", 10.5)787 c.setFillColor(INK)788 c.drawString(M, y - 2, (gest.get("nom") or "")[:60])789 n_act = gest.get("annonces_actives") or 0790 c.setFont("Helvetica", 8)791 c.setFillColor(INK2)792 c.drawString(M + c.stringWidth((gest.get("nom") or "")[:60],793 "Helvetica-Bold", 10.5) + 10, y - 2,794 f"{n_act} annonce{'s' if n_act > 1 else ''} active"795 f"{'s' if n_act > 1 else ''} sur Rent-Ka")796 y -= 16797 if g_.get("note") is not None:798 note_t = f"{g_['note']:.1f}".replace(".", ",")799 s.pilule(M, y - 10, f"Note Google {note_t} / 5", LIME, INK)800 c.setFont("Helvetica", 8)801 c.setFillColor(INK2)802 c.drawString(M + 110, y - 5,803 (f"{g_.get('nombre_avis', 0)}{NBSP}avis Google — fiche "804 f"« {g_.get('nom', '')} »")[:90])805 y -= 22806 if tot_g:807 bw_g = 150.0808 for n_ in (5, 4, 3, 2, 1):809 cnt = dist_g.get(str(n_), 0)810 c.setFont("Courier-Bold", 7)811 c.setFillColor(INK2)812 c.drawString(M, y, f"{n_}*")813 c.setFillColor(SURFACE)814 c.setStrokeColor(INK)815 c.setLineWidth(0.8)816 c.rect(M + 22, y - 1, bw_g, 7, stroke=1, fill=1)817 if cnt:818 c.setFillColor(GREEN if n_ >= 4 else (LIME if n_ <= 2 else INK3))819 c.rect(M + 22, y - 1, bw_g * cnt / tot_g, 7, stroke=0, fill=1)820 c.setFont("Courier-Bold", 7)821 c.setFillColor(INK)822 c.drawString(M + 22 + bw_g + 6, y, str(cnt))823 y -= 11824 y -= 2825 stats_g = []826 if avis_g.get("moyenne_12m") is not None:827 stats_g.append(f"moyenne des 12 derniers mois : "828 f"{avis_g['moyenne_12m']:.1f}".replace(".", ",")829 + f" ({avis_g.get('n_12m')} avis)")830 if avis_g.get("tendance"):831 stats_g.append(avis_g["tendance"])832 if avis_g.get("plaintes_frequentes"):833 stats_g.append("plaintes récurrentes : "834 + ", ".join(avis_g["plaintes_frequentes"][:4]))835 if stats_g:836 y = s.paragraphe(M, y, " · ".join(stats_g), PAGE_W - 2 * M,837 taille=8, couleur=INK2, interligne=10,838 max_lignes=2)839 y -= 2840 conf_g = round((g_.get("confiance_association") or 0) * 100)841 y = s.paragraphe(M, y - 2, f"Fiche Google Maps associée automatiquement "842 f"(confiance {conf_g}{NBSP}%). Statistiques sur "843 f"les {avis_g.get('n', 0)} avis synchronisés dans "844 "la base Rent-Ka — échantillon des plus récents, "845 "pas une lecture humaine.",846 PAGE_W - 2 * M, taille=6.5, couleur=INK3,847 interligne=8.5, max_lignes=3)848 y -= 6849850 # --- Registre des loyers (déclarations citoyennes à proximité)851 if rdl_d and rdl_d.get("n"):852 comp = rdl_d.get("items") or []853 y = place(y, 128 + min(len(comp), 8) * 12)854 y = s.titre_section(y, "Registre des loyers — déclarations citoyennes")855 tw = (PAGE_W - 2 * M - 20) / 3856 med_rec = rdl_d.get("median_recent")857 tuiles = [(str(rdl_d["n"]), f"loyers déclarés à <{rdl_d['radius_m']} m"),858 (_fmt_money(rdl_d.get("median")), "médiane du secteur"),859 (_fmt_money(med_rec) if med_rec else "—",860 f"médiane depuis 2023 ({rdl_d.get('n_recent', 0)} décl.)")]861 for i, (val, lab) in enumerate(tuiles):862 s.tuile(M + i * (tw + 10), y - 44, tw, 38, val, lab)863 y -= 56864 rooms = rdl_d.get("by_rooms") or {}865 if rooms:866 def _rk(k: str) -> float:867 try:868 return float(k)869 except ValueError:870 return 99.0871 parts = [f"{k}{NBSP}ch. {_fmt_money(v['median'])} ({v['n']})"872 for k, v in sorted(rooms.items(), key=lambda kv: _rk(kv[0]))]873 c.setFont("Helvetica", 8)874 c.setFillColor(INK2)875 c.drawString(M, y, ("Médianes par taille : " + " · ".join(parts))[:118])876 y -= 14877 if comp:878 c.setFont("Courier-Bold", 7)879 c.setFillColor(INK3)880 for lab, xoff in (("ADRESSE", 0), ("CH.", 280), ("DÉBUT DE BAIL", 320),881 ("DIST.", 420), ("LOYER", 480)):882 c.drawString(M + xoff, y, lab)883 y -= 4884 c.setStrokeColor(INK)885 c.setLineWidth(1)886 c.line(M, y, PAGE_W - M, y)887 y -= 11888 for it in comp[:8]:889 c.setFont("Helvetica", 8)890 c.setFillColor(INK2)891 c.drawString(M, y, (it.get("address") or "")[:52])892 c.drawString(M + 280, y, str(it["rooms"]) if it.get("rooms") is not None else "—")893 c.drawString(M + 320, y, _fmt_mois(it.get("date")))894 c.drawString(M + 420, y, _fmt_dist(it["dist_m"]))895 c.setFont("Helvetica-Bold", 8)896 c.setFillColor(INK)897 c.drawString(M + 480, y, _fmt_money(it.get("price")))898 y -= 12899 c.setFont("Helvetica", 6.5)900 c.setFillColor(INK3)901 c.drawString(M, y - 2, "Loyers déclarés volontairement par des locataires "902 "au Registre des loyers (registre-des-loyers.ca) — "903 "données citoyennes non vérifiées.")904 y -= 16905906 # --- Historique au TAL (décisions SOQUIJ à l'adresse)907 if tal_d and tal_d.get("status") in ("ok", "pending"):908 decs = (tal_d.get("decisions") or [])[:6]909 y = place(y, 70 + len(decs) * 24)910 y = s.titre_section(y, "Historique au Tribunal administratif du logement")911 if tal_d["status"] == "pending":912 c.setFont("Helvetica", 8.5)913 c.setFillColor(INK2)914 c.drawString(M, y - 4, "Adresse en file de vérification auprès des "915 "décisions publiées du TAL (résultat sur la "916 "fiche en ligne sous peu).")917 y -= 18918 elif not decs:919 s.pilule(M, y - 12, "Aucune décision trouvée", GREEN_DEEP,920 LIME_SOFT, GREEN)921 c.setFont("Helvetica", 8.5)922 c.setFillColor(INK2)923 c.drawString(M + 130, y - 7, "Aucune décision publiée du TAL n'a été "924 "repérée à cette adresse.")925 y -= 26926 else:927 n = tal_d.get("n") or len(decs)928 cl = tal_d.get("contre_locataire") or 0929 c.setFont("Helvetica-Bold", 9)930 c.setFillColor(INK)931 c.drawString(M, y - 4,932 f"{n} décision{'s' if n > 1 else ''} publiée"933 f"{'s' if n > 1 else ''} à cette adresse — "934 f"{cl} à l'initiative du propriétaire.")935 y -= 20936 for dec in decs:937 qui = {"locateur": "demande du propriétaire",938 "locataire": "demande du locataire"}.get(939 dec.get("demandeur") or "", "demandeur inconnu")940 c.setFont("Helvetica-Bold", 8)941 c.setFillColor(INK)942 c.drawString(M, y, _fmt_mois(dec.get("date")))943 c.setFont("Helvetica", 8)944 c.setFillColor(INK2)945 suite = qui946 if dec.get("verdict"):947 suite += f" · demande {dec['verdict']}"948 if dec.get("tags"):949 suite += " · " + ", ".join(dec["tags"][:3])950 c.drawString(M + 78, y, suite[:88])951 c.setFont("Courier-Bold", 7)952 c.setFillColor(GREEN)953 c.drawRightString(PAGE_W - M, y, dec.get("citation") or "SOQUIJ")954 y -= 12955 y -= 6956 c.setFont("Helvetica", 6.5)957 c.setFillColor(INK3)958 c.drawString(M, y - 2, "Décisions publiques diffusées par SOQUIJ, repérées "959 "par correspondance d'adresse — une décision peut "960 "concerner un autre logement du même immeuble.")961 y -= 16962963 # --- Risque d'inondation (BDZI)964 if inon:965 zones = (inon.get("zones") or [])[:3]966 y = place(y, 56 + len(zones) * 12)967 y = s.titre_section(y, "Risque d'inondation")968 statut = inon.get("statut")969 sev = {"eleve": "récurrence élevée", "modere": "récurrence modérée",970 "present": "zone cartographiée"}.get(inon.get("severite") or "", "")971 if statut == "en_zone":972 s.pilule(M, y - 12, f"En zone inondable ({sev})",973 HexColor("#a12622"), HexColor("#fde8e8"), HexColor("#a12622"))974 elif statut == "a_proximite":975 d0 = zones[0]["distance_m"] if zones else "?"976 s.pilule(M, y - 12, f"Zone inondable à ~{d0} m",977 HexColor("#8a5a12"), AMBER_SOFT, AMBER)978 elif statut == "hors_zone":979 s.pilule(M, y - 12, "Hors des zones inondables cartographiées",980 GREEN_DEEP, LIME_SOFT, GREEN)981 else:982 s.pilule(M, y - 12, "Secteur non couvert par la cartographie BDZI",983 INK2, SURFACE, INK)984 y -= 26985 for z in zones:986 c.setFont("Helvetica", 8)987 c.setFillColor(INK2)988 c.drawString(M, y, f"{z['type']} — à {z['distance_m']} m"989 + (f" (rapport {z['date_rapport']})"990 if z.get("date_rapport") else ""))991 y -= 12992 y -= 8993994 # --- Qualité de l'air (RSQAQ)995 if air_d and air_d.get("station"):996 mes = air_d.get("mesures") or {}997 y = place(y, 44 + len(mes) * 11)998 y = s.titre_section(y, "Qualité de l'air")999 c.setFont("Helvetica", 8.5)1000 c.setFillColor(INK2)1001 c.drawString(M, y - 2,1002 f"Station {air_d['station']} ({air_d['ville']}), à "1003 + f"{air_d['distance_km']}".replace(".", ",") + f"{NBSP}km "1004 "du logement — moyennes annuelles :")1005 y -= 161006 for pol, m_ in list(mes.items())[:5]:1007 c.setFont("Helvetica-Bold", 8)1008 c.setFillColor(INK)1009 c.drawString(M, y, pol)1010 c.setFont("Helvetica", 8)1011 c.setFillColor(INK2)1012 val = f"{m_['moyenne']:.1f}".replace(".", ",") if m_.get("moyenne") is not None else "—"1013 txt = f"{val}{NBSP}{m_.get('unite', '')} ({m_.get('annee', '')})"1014 if m_.get("ref"):1015 txt += f" · référence {m_.get('ref_nom', '')} : {m_['ref']}"1016 c.drawString(M + 56, y, txt[:95])1017 y -= 111018 y -= 810191020 # --- Coût d'électricité (Hydro-Québec)1021 if hyd and hyd.get("disponible"):1022 y = place(y, 52)1023 y = s.titre_section(y, "Coût d'électricité (Hydro-Québec)")1024 c.setFont("Helvetica-Bold", 15)1025 c.setFillColor(INK)1026 mens = f"≈{NBSP}{_fmt_money(round(hyd['cout_mensuel']))} / mois"1027 c.drawString(M, y - 8, mens)1028 c.setFont("Helvetica", 8.5)1029 c.setFillColor(INK2)1030 suite = f"{_fmt_money(round(hyd['cout_annuel']))} / an"1031 if hyd.get("kwh_annuel"):1032 suite += f" · {round(hyd['kwh_annuel']):,}".replace(",", NBSP) + f"{NBSP}kWh/an"1033 suite += " — estimation officielle Hydro-Québec pour cette adresse."1034 c.drawString(M + c.stringWidth(mens, "Helvetica-Bold", 15) + 10, y - 8, suite)1035 y -= 2610361037 # --- Commerces et transport1038 if com and (com.get("commerces") or com.get("transit")):1039 entrees = ([(t["commerce"], t["nom"], t["dist_m"])1040 for t in (com.get("transit") or [])[:4]]1041 + [(i["commerce"], i["nom"], i["dist_m"])1042 for i in (com.get("commerces") or [])[:10]])1043 lignes = (len(entrees) + 1) // 21044 y = place(y, 36 + lignes * 13)1045 y = s.titre_section(y, "Commerces et transport")1046 col_w = (PAGE_W - 2 * M) / 21047 for i, (cat, nom, dist) in enumerate(entrees):1048 px = M if i % 2 == 0 else M + col_w + 101049 py = y - (i // 2) * 131050 c.setFont("Helvetica", 8)1051 c.setFillColor(INK2)1052 nomc = nom if len(nom) <= 22 else nom[:21] + "…"1053 c.drawString(px, py, f"{cat} — {nomc}")1054 c.setFont("Courier-Bold", 7.5)1055 c.setFillColor(INK)1056 c.drawRightString(px + col_w - 14, py, _fmt_dist(dist))1057 y -= lignes * 13 + 1210581059 # --- Essence à proximité1060 if gaz_d and gaz_d.get("stations"):1061 sts = gaz_d["stations"][:3]1062 y = place(y, 40 + len(sts) * 11)1063 y = s.titre_section(y, "Essence à proximité")1064 if gaz_d.get("mediane_regulier"):1065 c.setFont("Helvetica", 8.5)1066 c.setFillColor(INK2)1067 c.drawString(M, y - 2, "Prix médian de l'essence régulière à <5 km : "1068 + f"{gaz_d['mediane_regulier']}".replace(".", ",")1069 + f"{NBSP}¢/L")1070 y -= 141071 for st in sts:1072 c.setFont("Helvetica", 8)1073 c.setFillColor(INK2)1074 reg_p = (f"{st['regulier']}".replace(".", ",") + f"{NBSP}¢/L"1075 if st.get("regulier") else "—")1076 c.drawString(M, y, f"{st['nom']} — {_fmt_dist(st['dist_m'])} : {reg_p}"1077 + (" · la moins chère" if st.get("moins_chere") else ""))1078 y -= 111079 y -= 810801081 # --- KA Scores (immeuble et environnement)1082 if kasc and kasc.get("glob") is not None:1083 from . import kascores as ks_mod1084 y = place(y, 92)1085 y = s.titre_section(y, "KA Scores")1086 paires = [("Global", kasc.get("glob")), ("À pied", kasc.get("walk")),1087 ("Transport", kasc.get("transit")), ("Vélo", kasc.get("bike")),1088 ("Calme", kasc.get("calme")), ("Services", kasc.get("services"))]1089 tw = (PAGE_W - 2 * M - 5 * 8) / 61090 for i, (lab, v) in enumerate(paires):1091 val = "—" if v is None else str(round(v))1092 sub = ks_mod.label(v) or ""1093 s.tuile(M + i * (tw + 8), y - 46, tw, 42, val,1094 f"{lab} · {sub}" if sub else lab)1095 y -= 6210961097 # --- Vie quotidienne en hiver (quotidien à pied, même à −20 °C)1098 if hiv:1099 det_h = hiv.get("detail") or []1100 y = place(y, 52 + len(det_h) * 11 + 14)1101 y = s.titre_section(y, "Vie quotidienne en hiver")1102 c.setFont("Helvetica-Bold", 17)1103 c.setFillColor(INK)1104 sc_t = str(hiv.get("score", "—"))1105 c.drawString(M, y - 8, sc_t)1106 s.pilule(M + c.stringWidth(sc_t, "Helvetica-Bold", 17) + 10, y - 12,1107 (hiv.get("classe") or "").upper())1108 c.setFont("Helvetica", 8)1109 c.setFillColor(INK3)1110 c.drawString(M + c.stringWidth(sc_t, "Helvetica-Bold", 17) + 105, y - 7,1111 "quotidien à pied, même à −20 °C")1112 y -= 261113 for cr_ in det_h:1114 c.setFont("Helvetica-Bold", 8)1115 c.setFillColor(INK)1116 c.drawString(M, y, (cr_.get("critere") or "")[:24])1117 c.setFont("Helvetica", 8)1118 c.setFillColor(INK2)1119 if cr_.get("distance_m") is not None:1120 val_h = f"≈{NBSP}{cr_.get('minutes')}{NBSP}min à pied"1121 if cr_.get("nom"):1122 val_h += f" ({cr_['nom']})"1123 else:1124 val_h = cr_.get("note") or "—"1125 c.drawString(M + 90, y, val_h[:80])1126 c.setFont("Courier-Bold", 7.5)1127 c.setFillColor(INK)1128 c.drawRightString(PAGE_W - M, y, str(cr_.get("score", "—")))1129 y -= 111130 c.setFont("Helvetica", 6.5)1131 c.setFillColor(INK3)1132 c.drawString(M, y - 2, (hiv.get("methode") or "")[:135])1133 y -= 1611341135 # --- Le quartier1136 if quartier:1137 y = place(y, 270)1138 y = s.titre_section(y, "Le quartier")1139 demo = quartier.get("demographie") or {}1140 tuiles = [("Revenu médian", _fmt_money(demo.get("revenu_median"))),1141 ("Ménages locataires", f"{round(demo['pct_locataires'])}{NBSP}%" if demo.get("pct_locataires") is not None else "—"),1142 ("Loyer moyen secteur", _fmt_money(demo.get("loyer_moyen"))),1143 ("Âge médian", f"{round(demo['age_median'])} ans" if demo.get("age_median") is not None else "—"),1144 ("Français à la maison", f"{round(demo['pct_francais'])}{NBSP}%" if demo.get("pct_francais") is not None else "—"),1145 ("Diplôme universitaire", f"{round(demo['pct_univ'])}{NBSP}%" if demo.get("pct_univ") is not None else "—")]1146 tw = (PAGE_W - 2 * M - 20) / 31147 for i, (lab, val) in enumerate(tuiles):1148 s.tuile(M + (i % 3) * (tw + 10), y - 34 - (i // 3) * 44,1149 tw, 38, val, lab)1150 y -= 34 + 44 + 1611511152 prox = quartier.get("proximite") or {}1153 for k, lab in PROX_LABELS:1154 if k not in prox:1155 continue1156 v = max(0.0, min(1.0, prox[k]))1157 c.setFont("Helvetica", 8)1158 c.setFillColor(INK2)1159 c.drawString(M, y, lab)1160 bx0, bw_ = M + 130, PAGE_W - 2 * M - 1601161 c.setFillColor(SURFACE)1162 c.setStrokeColor(INK)1163 c.setLineWidth(0.8)1164 c.roundRect(bx0, y - 1, bw_, 7, 3.5, stroke=1, fill=1)1165 c.setFillColor(GREEN)1166 c.roundRect(bx0, y - 1, bw_ * v, 7, 3.5, stroke=0, fill=1)1167 c.setFont("Courier-Bold", 7.5)1168 c.setFillColor(INK)1169 c.drawRightString(PAGE_W - M, y, str(round(v * 100)))1170 y -= 141171 y -= 41172 badges_q = []1173 ch = quartier.get("chaleur")1174 if ch:1175 if ch["classe"] <= 3:1176 badges_q.append(("Îlot de fraîcheur", GREEN_DEEP, LIME_SOFT, GREEN))1177 elif ch["classe"] >= 7:1178 badges_q.append((f"Îlot de chaleur (+{ch['ecart']:.1f} °C)".replace(".", ","),1179 HexColor("#8a5a12"), AMBER_SOFT, AMBER))1180 cr = quartier.get("crime")1181 if cr and cr.get("type") == "igc" and cr.get("indice_canada"):1182 dlt = round(100 * (cr["indice"] - cr["indice_canada"]) / cr["indice_canada"])1183 badges_q.append((f"Criminalité {abs(dlt)}{NBSP}% "1184 f"{'sous' if dlt <= 0 else 'au-dessus de'} la moyenne canadienne",1185 INK2, SURFACE, INK))1186 elif cr and cr.get("type") == "points":1187 badges_q.append((f"{cr['douze_mois']} actes criminels à <500 m (12 mois)",1188 INK2, SURFACE, INK))1189 x = M1190 for txt, fg, bg, stk in badges_q:1191 x = s.pilule(x, y - 8, txt, fg, bg, stk)1192 y -= 3011931194 # --- À proximité (commodités du cache POI)1195 if poi:1196 lignes = (min(len(poi), 12) + 1) // 21197 y = place(y, 36 + lignes * 13)1198 y = s.titre_section(y, "À proximité")1199 col_w = (PAGE_W - 2 * M) / 21200 for i, p in enumerate(poi[:12]):1201 px = M if i % 2 == 0 else M + col_w + 101202 py = y - (i // 2) * 131203 c.setFont("Helvetica", 8)1204 c.setFillColor(INK2)1205 lab = POI_LABELS.get(p["cat"], p["cat"])1206 nom_poi = p["name"][:19] + "…" if len(p["name"]) > 20 else p["name"]1207 c.drawString(px, py, f"{lab} — {nom_poi}")1208 c.setFont("Courier-Bold", 7.5)1209 c.setFillColor(INK)1210 c.drawRightString(px + col_w - 14, py,1211 f"{_fmt_dist(p['dist_m'])} · {_marche(p['dist_m'])}")1212 y -= lignes * 13 + 1412131214 # --- Références : QR + gestionnaire + source1215 y = place(y, 120)1216 y = s.titre_section(y, "Références")1217 qr = qrcode.make(f"https://www.rent-ka.com/listing/{uid}", box_size=4, border=1)1218 qb = io.BytesIO()1219 qr.save(qb, format="PNG")1220 c.drawImage(ImageReader(io.BytesIO(qb.getvalue())), M, y - 74, 70, 70)1221 c.setFont("Helvetica-Bold", 9)1222 c.setFillColor(INK)1223 c.drawString(M + 82, y - 14, f"Gestionnaire : {src_name}")1224 c.setFont("Helvetica", 8)1225 c.setFillColor(INK2)1226 c.drawString(M + 82, y - 28, "Annonce originale :")1227 c.setFillColor(GREEN)1228 c.drawString(M + 82, y - 40, (l.get("url") or "")[:90])1229 c.setFillColor(INK2)1230 c.drawString(M + 82, y - 56, "Fiche à jour en ligne (scannez le code) :")1231 c.setFillColor(GREEN)1232 c.drawString(M + 82, y - 68, f"www.rent-ka.com/listing/{uid}")1233 y -= 861234 c.setFont("Helvetica", 6.5)1235 c.setFillColor(INK3)1236 c.drawString(M, y, "Sources : Statistique Canada (2021), INSPQ (CC-BY 4.0), "1237 "Ville de Montréal (CC-BY 4.0), OpenStreetMap, BDZI, RSQAQ, "1238 "Hydro-Québec, registre-des-loyers.ca, SOQUIJ, gazquebec.ca.")12391240 s.pied(_PIED_FICHE, page[0])1241 c.save()1242 return buf.getvalue()124312441245def s_titre_droite(c, y, x, texte):1246 c.setFont("Helvetica-Bold", 12.5)1247 c.setFillColor(INK)1248 c.drawString(x, y, texte)1249 c.setStrokeColor(LIME)1250 c.setLineWidth(3)1251 c.line(x, y - 4, x + c.stringWidth(texte, "Helvetica-Bold", 12.5), y - 4)1252 return y - 18125312541255# ---------------------------------------------------------------------------1256# Rapport global du marché1257# ---------------------------------------------------------------------------12581259_REGIONS = [1260 ("Québec métro", {"Québec", "Lévis", "Saint-Augustin-de-Desmaures", "L'Ancienne-Lorette", "Pont-Rouge", "Shannon"}),1261 ("Outaouais", {"Gatineau", "Chelsea", "Thurso", "Perkins", "Maniwaki", "Val-des-Monts"}),1262 ("Estrie / Montérégie-Est", {"Sherbrooke", "Magog", "Orford", "East Angus", "Waterville", "Granby", "Waterloo", "Bromont", "Cowansville", "Richmond"}),1263 ("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"}),1264 ("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"}),1265 ("Bas-Saint-Laurent / Gaspésie", {"Rimouski", "Rivière-du-Loup", "Matane", "Saint-Ulric", "Amqui", "Le Bic", "New Richmond", "Carleton-sur-Mer", "Gaspé"}),1266 ("Saguenay–Lac-Saint-Jean", {"Saguenay", "Alma", "Chicoutimi", "Jonquière", "Chambord", "La Malbaie"}),1267 ("Abitibi-Témiscamingue", {"Rouyn-Noranda", "Val-d'Or", "Amos", "Malartic"}),1268 ("Côte-Nord", {"Sept-Îles", "Port-Cartier", "Baie-Comeau", "Forestville"}),1269 ("Chaudière-Appalaches", {"Saint-Georges", "Sainte-Marie", "Thetford Mines", "Montmagny", "Vallée-Jonction", "Scott", "Saint-Isidore", "La Guadeloupe", "Saint-Raphaël"}),1270]127112721273def _region_de(city: str) -> str:1274 for nom, villes in _REGIONS:1275 if city in villes:1276 return nom1277 return "Grand Montréal & environs"127812791280def rapport_pdf() -> bytes:1281 """Rapport global du marché locatif Rent-Ka (multi-pages).12821283 Tous les chiffres viennent de marketstats.compute() — la même source1284 que la page Statistiques du site.1285 """1286 from . import marketstats1287 st = marketstats.compute()1288 reg_file = json.loads((db.DB_PATH.parent / "sources.json").read_text("utf-8"))["sources"]1289 noms = {s["id"]: s["name"] for s in reg_file}12901291 t = st["totals"]1292 total, moy, med, gps = t["total"], t["avg"] or 0, t["median"] or 0, t["gps_pct"]1293 n_sources = t["sources"]12941295 buf = io.BytesIO()1296 c = rl_canvas.Canvas(buf, pagesize=letter)1297 c.setTitle("Rent-Ka — Rapport du marché locatif")1298 s = _Style(c)12991300 # ---- page 1 : couverture + grands chiffres + régions1301 s.fond()1302 s.logo(M, PAGE_H - 130, 42)1303 c.setFont("Helvetica-Bold", 24)1304 c.setFillColor(INK)1305 c.drawString(M, PAGE_H - 185, "Rapport du marché locatif")1306 c.setFont("Courier-Bold", 9)1307 c.setFillColor(INK3)1308 c.drawString(M, PAGE_H - 205,1309 time.strftime("PROVINCE DE QUÉBEC · GÉNÉRÉ LE %Y-%m-%d · WWW.LOU-KA.COM"))13101311 grands = [(f"{total:,}".replace(",", NBSP), "annonces actives"),1312 (str(n_sources), "gestionnaires connectés"),1313 (_fmt_money(moy), "loyer moyen"),1314 (_fmt_money(med), "loyer médian"),1315 (f"{gps}{NBSP}%", "annonces géolocalisées")]1316 tw = (PAGE_W - 2 * M - 4 * 10) / 51317 for i, (val, lab) in enumerate(grands):1318 tx = M + i * (tw + 10)1319 ty = PAGE_H - 2851320 c.setFillColor(INK)1321 c.roundRect(tx + 3, ty - 3, tw, 58, 6, stroke=0, fill=1) # ombre décalée1322 c.setFillColor(SURFACE)1323 c.setStrokeColor(INK)1324 c.setLineWidth(1.5)1325 c.roundRect(tx, ty, tw, 58, 6, stroke=1, fill=1)1326 c.setFont("Helvetica-Bold", 15)1327 c.setFillColor(INK)1328 c.drawCentredString(tx + tw / 2, ty + 32, val)1329 c.setFont("Courier", 6.3)1330 c.setFillColor(INK3)1331 c.drawCentredString(tx + tw / 2, ty + 14, lab.upper())13321333 y = s.titre_section(PAGE_H - 330, "Couverture par région")1334 c.setFont("Courier-Bold", 7)1335 c.setFillColor(INK3)1336 for lab, xoff in (("RÉGION", 0), ("ANNONCES", 280), ("SOURCES", 360),1337 ("LOYER MOYEN", 430)):1338 c.drawString(M + xoff, y, lab)1339 y -= 41340 c.setStrokeColor(INK)1341 c.setLineWidth(1)1342 c.line(M, y, PAGE_W - M, y)1343 y -= 141344 regions = st["by_region"]1345 max_n = max((r["count"] for r in regions), default=1)1346 for r in regions:1347 c.setFont("Helvetica-Bold", 8.5)1348 c.setFillColor(INK)1349 c.drawString(M, y, r["key"])1350 c.setFillColor(LIME)1351 c.setStrokeColor(INK)1352 c.setLineWidth(0.7)1353 bw = 90 * r["count"] / max_n1354 c.roundRect(M + 180, y - 1, max(3, bw), 8, 3, stroke=1, fill=1)1355 c.setFont("Helvetica", 8.5)1356 c.setFillColor(INK2)1357 c.drawRightString(M + 330, y, f"{r['count']:,}".replace(",", NBSP))1358 c.drawRightString(M + 400, y, str(r["sources"]))1359 c.drawRightString(M + 500, y, _fmt_money(r["avg_price"]) if r["avg_price"] else "—")1360 y -= 151361 s.pied("Rapport généré automatiquement à partir des annonces publiques "1362 "agrégées par Rent-Ka. Loyers : bornes 300–10 000 $.", 1)1363 c.showPage()13641365 # ---- page 2 : histogramme des loyers + par type1366 s.fond()1367 y = s.entete("Rapport du marché · loyers")1368 y = s.titre_section(y, "Distribution des loyers")1369 hist = st["histogram"]1370 max_c = max((h["count"] for h in hist), default=1) or 11371 ch_h, ch_y = 150, y - 1701372 bw = (PAGE_W - 2 * M) / len(hist)1373 for i, h in enumerate(hist):1374 bh = ch_h * h["count"] / max_c1375 bx = M + i * bw1376 borne = h["hi"] is None or h["lo"] == 01377 c.setFillColor(INK3 if borne else GREEN)1378 c.setStrokeColor(INK)1379 c.setLineWidth(0.6)1380 c.rect(bx + 2, ch_y, bw - 4, max(1, bh), stroke=1, fill=1)1381 if h["count"] and h["count"] > max_c * 0.06:1382 c.setFont("Courier-Bold", 6)1383 c.setFillColor(INK)1384 c.drawCentredString(bx + bw / 2, ch_y + bh + 3, str(h["count"]))1385 c.setFont("Helvetica", 5.6)1386 c.setFillColor(INK3)1387 lab = f"<{h['hi']}" if h["lo"] == 0 else (f"{h['lo']}+" if h["hi"] is None1388 else str(h["lo"]))1389 c.drawCentredString(bx + bw / 2, ch_y - 9, lab)1390 y = ch_y - 3013911392 y = s.titre_section(y, "Par taille de logement")1393 c.setFont("Courier-Bold", 7)1394 c.setFillColor(INK3)1395 for lab, xoff in (("TAILLE", 0), ("ANNONCES", 160), ("LOYER MOYEN", 260),1396 ("LOYER MIN", 360)):1397 c.drawString(M + xoff, y, lab)1398 y -= 41399 c.line(M, y, PAGE_W - M, y)1400 y -= 131401 for r in st["by_type"][:9]:1402 c.setFont("Helvetica-Bold", 8.5)1403 c.setFillColor(INK)1404 c.drawString(M, y, r["key"])1405 c.setFont("Helvetica", 8.5)1406 c.setFillColor(INK2)1407 c.drawRightString(M + 220, y, str(r["count"]))1408 c.drawRightString(M + 330, y, _fmt_money(r["avg_price"]) if r["avg_price"] else "—")1409 c.drawRightString(M + 430, y, _fmt_money(r["min_price"]) if r["min_price"] else "—")1410 y -= 131411 s.pied("Rent-Ka — agrégateur indépendant. Chaque annonce renvoie à la "1412 "source originale du gestionnaire.", 2)1413 c.showPage()14141415 # ---- page 3 : offre, inclusions, prix au pi², baisses de prix1416 s.fond()1417 y = s.entete("Rapport du marché · offre")1418 o = st["offre"]1419 y = s.titre_section(y, "Inclusions et caractéristiques du parc")1420 carac = [("Chauffage inclus", o["chauffage_pct"]),1421 ("Électricité incluse", o["electricite_pct"]),1422 ("Eau chaude incluse", o["eau_chaude_pct"]),1423 ("Internet inclus", o["internet_pct"]),1424 ("Climatisation", o["clim_pct"]),1425 ("Stationnement", o["stationnement_pct"]),1426 ("Balcon", o["balcon_pct"]),1427 ("Meublé", o["furnished_pct"])]1428 for lab, v in carac:1429 if v is None:1430 continue1431 c.setFont("Helvetica", 8.5)1432 c.setFillColor(INK2)1433 c.drawString(M, y, lab)1434 bx0, bw_ = M + 150, PAGE_W - 2 * M - 2001435 c.setFillColor(SURFACE)1436 c.setStrokeColor(INK)1437 c.setLineWidth(0.8)1438 c.roundRect(bx0, y - 1, bw_, 8, 4, stroke=1, fill=1)1439 c.setFillColor(GREEN)1440 c.roundRect(bx0, y - 1, bw_ * min(1, v / 100), 8, 4, stroke=0, fill=1)1441 c.setFont("Courier-Bold", 8)1442 c.setFillColor(INK)1443 c.drawRightString(PAGE_W - M, y, f"{v}{NBSP}%")1444 y -= 151445 y -= 61446 c.setFont("Helvetica", 8)1447 c.setFillColor(INK3)1448 c.drawString(M, y, f"Disponibles maintenant : {o['dispo_now']:,} · à date future : "1449 f"{o['dispo_date']:,} · superficie moyenne : "1450 f"{o['superficie_moyenne'] or '—'} pi² "1451 f"({o['superficie_connue']:,} annonces la publient)"1452 .replace(",", NBSP))1453 y -= 2414541455 if o["prix_pi2"]:1456 y = s.titre_section(y, "Prix au pied carré (loyer / superficie)")1457 for r in o["prix_pi2"][:6]:1458 c.setFont("Helvetica-Bold", 8.5)1459 c.setFillColor(INK)1460 c.drawString(M, y, r["key"])1461 c.setFont("Helvetica", 8.5)1462 c.setFillColor(INK2)1463 c.drawString(M + 70, y, f"{r['val']:.2f}".replace(".", ",") +1464 f"{NBSP}$/pi² · {r['count']} annonces")1465 y -= 131466 y -= 1014671468 if st["baisses"]:1469 y = s.titre_section(y, "Baisses de prix récentes (30 jours)")1470 for b in st["baisses"][:8]:1471 c.setFont("Helvetica", 8.5)1472 c.setFillColor(INK2)1473 c.drawString(M, y, f"{(b['title'] or b['uid'])[:46]} — {b['city'] or ''}")1474 c.setFont("Courier-Bold", 8)1475 c.setFillColor(GREEN)1476 c.drawRightString(PAGE_W - M, y,1477 f"{_fmt_money(b['avant'])} → {_fmt_money(b['apres'])} "1478 f"({b['pct']}{NBSP}%)".replace(".", ","))1479 y -= 131480 s.pied("Caractéristiques dérivées des annonces publiées ; les inclusions "1481 "non mentionnées par une source ne sont pas comptées.", 3)1482 c.showPage()14831484 # ---- page 4 : top villes + top gestionnaires1485 s.fond()1486 y = s.entete("Rapport du marché · détail")1487 y = s.titre_section(y, "Top 20 des villes")1488 col_w = (PAGE_W - 2 * M) / 21489 for i, r in enumerate(st["by_city"][:20]):1490 vx = M if i < 10 else M + col_w1491 vy = y - (i % 10) * 131492 c.setFont("Helvetica", 8.5)1493 c.setFillColor(INK2)1494 c.drawString(vx, vy, f"{i + 1:>2}. {r['key']}")1495 c.setFont("Courier-Bold", 8)1496 c.setFillColor(INK)1497 c.drawRightString(vx + col_w - 24, vy, f"{r['count']:,}".replace(",", NBSP))1498 y -= 10 * 13 + 1614991500 y = s.titre_section(y, "Top 20 des gestionnaires")1501 for i, r in enumerate(st["by_source"][:20]):1502 vx = M if i < 10 else M + col_w1503 vy = y - (i % 10) * 131504 c.setFont("Helvetica", 8.5)1505 c.setFillColor(INK2)1506 c.drawString(vx, vy, f"{i + 1:>2}. {noms.get(r['key'], r['key'])[:34]}")1507 c.setFont("Courier-Bold", 8)1508 c.setFillColor(INK)1509 c.drawRightString(vx + col_w - 24, vy, f"{r['count']:,}".replace(",", NBSP))1510 y -= 10 * 13 + 2015111512 c.setFont("Helvetica", 7.5)1513 c.setFillColor(INK3)1514 c.drawString(M, y, "Sources de données de quartier : Statistique Canada (Recensement 2021, "1515 "licence ouverte), INSPQ (CC-BY 4.0), Ville de Montréal (CC-BY 4.0), OpenStreetMap.")1516 s.pied("© Rent-Ka — www.rent-ka.com · rapport non contractuel, généré automatiquement.", 4)1517 c.save()1518 return buf.getvalue()1519