SPB Git

spb/lou-ka Public

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

HTML 99.7%
31.2 KB · 773 lines python
Raw Blame History
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