SPB Git forge

spb/immo-ka

Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%
38.6 KB · 950 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# pdffiche.py : fiche PDF complète d'une propriété — reflète TOUS les blocs de5#   la fiche web : prix + verdict Vrai-Prix, photos, description, inclusions,6#   caractéristiques, pièces, analyse Vrai-Prix (jauge + rôle foncier),7#   historique de prix, inondation, qualité de l'air, coût d'électricité,8#   commerces/transport, essence, quartier, références + QR.9#   Style « éditorial sharp » (bordures encre, ombres décalées dures, tuiles10#   KPI, micro-étiquettes Courier) — palette du site : crème #f5f3ee, encre11#   #141814, accent cerise #e23744. Pagination dynamique : saut_page()/place(),12#   aucune section coupée entre deux pages.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import io17import json18import 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 Immo-Ka (identité officielle du site) ----------------------------30PAPER = HexColor("#f5f3ee")31SURFACE = HexColor("#ffffff")32INK = HexColor("#141814")33INK2 = HexColor("#4d5551")34INK3 = HexColor("#8b928c")35ACCENT = HexColor("#e23744")        # cerise36ACCENT_SOFT = HexColor("#fbe0e2")37ACCENT_DEEP = HexColor("#a8232e")38GREEN_FG = HexColor("#1c5c41")      # verdict « sous l'estimation » (site .vp-under)39GREEN_BG = HexColor("#e2f0e6")40RED_FG = HexColor("#8a2b2b")        # verdict « au-dessus » (site .vp-over)41RED_BG = HexColor("#f6e2e2")42AMBER_FG = HexColor("#8a5a12")43AMBER_BG = HexColor("#faf0dc")4445PAGE_W, PAGE_H = letter46M = 40          # marge47IMG_TIMEOUT = 648UA = "ImmoKaBot/1.0 (+https://www.immo-ka.com; contact@spboucher.ai)"4950NBSP = " "     # espace fine insécable (mise en forme française)5152# clés de details jamais affichées (mêmes exclusions que la fiche web)53DETAIL_HIDDEN = {54    "pieces", "price_from", "cover_thumb", "photo_captions", "img_audited",55    "needs_image_review", "postal_code", "region", "transaction",56    "prix_pi2", "prix_m2", "listing_origin_url",57}5859PROX_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]6465_MOIS = ["janvier", "février", "mars", "avril", "mai", "juin", "juillet",66         "août", "septembre", "octobre", "novembre", "décembre"]6768_PIED_FICHE = ("Données de l'annonce publiée par l'agence — Immo-Ka est un "69               "agrégateur indépendant. Enrichissements fournis à titre "70               "indicatif, sans valeur légale.")717273def _fmt_money(v) -> str:74    return f"{v:,.0f}".replace(",", f"{NBSP}") + f"{NBSP}$" if v is not None else "—"757677def _fmt_dist(m_: float) -> str:78    return (f"{round(m_ / 10) * 10}{NBSP}m" if m_ < 100079            else f"{m_ / 1000:.1f}".replace(".", ",") + f"{NBSP}km")808182def _fmt_mois(d) -> str:83    """« 2017-11-04 » ou epoch → « novembre 2017 » (sinon valeur telle quelle)."""84    if d is None:85        return "—"86    try:87        if isinstance(d, (int, float)):88            st = time.localtime(d)89            return f"{_MOIS[st.tm_mon - 1]} {st.tm_year}"90        an, mois = str(d)[:7].split("-")91        return f"{_MOIS[int(mois) - 1]} {an}"92    except Exception:93        return str(d)949596def _fmt_jour(ts: float) -> str:97    """epoch → « 4 novembre 2024 » (« 1er » pour le premier du mois)."""98    st = time.localtime(ts)99    j = "1er" if st.tm_mday == 1 else str(st.tm_mday)100    return f"{j} {_MOIS[st.tm_mon - 1]} {st.tm_year}"101102103def _fetch_image(url: str) -> ImageReader | None:104    try:105        r = requests.get(url, timeout=IMG_TIMEOUT, headers={"User-Agent": UA})106        r.raise_for_status()107        raw = r.content108        try:109            # les photos source dépassent parfois 5 Mo chacune : sans110            # redimensionnement le PDF grimpe à plusieurs dizaines de Mo111            from PIL import Image112            im = Image.open(io.BytesIO(raw))113            im.thumbnail((1000, 1000))114            buf = io.BytesIO()115            im.convert("RGB").save(buf, "JPEG", quality=78)116            buf.seek(0)117            return ImageReader(buf)118        except Exception:119            return ImageReader(io.BytesIO(raw))120    except Exception:121        return None122123124class _Style:125    """Petits composants du style « éditorial sharp » sur un canvas."""126127    def __init__(self, c: rl_canvas.Canvas):128        self.c = c129130    def fond(self):131        self.c.setFillColor(PAPER)132        self.c.rect(0, 0, PAGE_W, PAGE_H, stroke=0, fill=1)133134    def logo(self, x: float, y: float, taille: float = 22):135        """Wordmark Immo·Ka : « Immo » encre + boîte encre avec « Ka » cerise."""136        c = self.c137        c.setFont("Helvetica-Bold", taille)138        c.setFillColor(INK)139        c.drawString(x, y, "Immo")140        w = c.stringWidth("Immo", "Helvetica-Bold", taille)141        bw = c.stringWidth("Ka", "Helvetica-Bold", taille) + 8142        c.saveState()143        c.translate(x + w + 3 + bw / 2, y + taille * 0.32)144        c.rotate(-3)145        c.setFillColor(INK)146        c.roundRect(-bw / 2, -taille * 0.62, bw, taille * 1.15, 4, stroke=0, fill=1)147        c.setFillColor(ACCENT)148        c.drawCentredString(0, -taille * 0.30, "Ka")149        c.restoreState()150        return x + w + 6 + bw151152    def entete(self, titre: str):153        c = self.c154        self.logo(M, PAGE_H - M - 16)155        c.setFont("Courier-Bold", 8)156        c.setFillColor(INK3)157        c.drawRightString(PAGE_W - M, PAGE_H - M - 6, titre.upper())158        c.drawRightString(PAGE_W - M, PAGE_H - M - 16,159                          time.strftime("GÉNÉRÉ LE %Y-%m-%d · WWW.IMMO-KA.COM"))160        c.setStrokeColor(INK)161        c.setLineWidth(2)162        c.line(M, PAGE_H - M - 26, PAGE_W - M, PAGE_H - M - 26)163        return PAGE_H - M - 40   # y de départ du contenu164165    def pied(self, texte: str, page: int | None = None):166        c = self.c167        c.setStrokeColor(INK)168        c.setLineWidth(1)169        c.line(M, M + 16, PAGE_W - M, M + 16)170        c.setFont("Helvetica", 6.5)171        c.setFillColor(INK3)172        c.drawString(M, M + 6, texte[:150])173        if page:174            c.setFont("Courier-Bold", 7)175            c.drawRightString(PAGE_W - M, M + 6, f"P.{page}")176177    def pilule(self, x: float, y: float, texte: str, fg=ACCENT, bg=INK,178               stroke=None, taille: float = 8) -> float:179        """Chip arrondie ; retourne le x suivant."""180        c = self.c181        w = c.stringWidth(texte, "Helvetica-Bold", taille) + 14182        c.setFillColor(bg)183        if stroke:184            c.setStrokeColor(stroke)185            c.setLineWidth(1.2)186        c.roundRect(x, y, w, taille + 9, (taille + 9) / 2,187                    stroke=1 if stroke else 0, fill=1)188        c.setFillColor(fg)189        c.setFont("Helvetica-Bold", taille)190        c.drawString(x + 7, y + 5, texte)191        return x + w + 6192193    def titre_section(self, y: float, texte: str) -> float:194        c = self.c195        # carré accent à ombre dure (signature de la passe éditoriale du site)196        c.setFillColor(INK)197        c.rect(M + 2, y - 1.5, 9, 9, stroke=0, fill=1)198        c.setFillColor(ACCENT)199        c.setStrokeColor(INK)200        c.setLineWidth(1)201        c.rect(M, y + 0.5, 9, 9, stroke=1, fill=1)202        c.setFont("Helvetica-Bold", 12.5)203        c.setFillColor(INK)204        c.drawString(M + 16, y, texte)205        c.setStrokeColor(ACCENT)206        c.setLineWidth(3)207        w = c.stringWidth(texte, "Helvetica-Bold", 12.5)208        c.line(M + 16, y - 4, M + 16 + w, y - 4)209        return y - 18210211    def paragraphe(self, x: float, y: float, texte: str, largeur: float,212                   taille: float = 8.5, couleur=INK2, interligne: float = 11.5,213                   max_lignes: int = 100) -> float:214        """Texte multi-lignes avec découpe aux mots ; retourne le y suivant."""215        c = self.c216        c.setFont("Helvetica", taille)217        c.setFillColor(couleur)218        mots = texte.split()219        ligne = ""220        n = 0221        for mot in mots:222            essai = (ligne + " " + mot).strip()223            if c.stringWidth(essai, "Helvetica", taille) <= largeur:224                ligne = essai225            else:226                c.drawString(x, y, ligne)227                y -= interligne228                n += 1229                ligne = mot230                if n >= max_lignes - 1:231                    ligne += " …"232                    break233        if ligne:234            c.drawString(x, y, ligne)235            y -= interligne236        return y237238    def nb_lignes(self, texte: str, largeur: float, taille: float = 8.5,239                  max_lignes: int = 100) -> int:240        """Nombre de lignes qu'occupera `paragraphe` (pour réserver la place)."""241        c = self.c242        mots = texte.split()243        ligne = ""244        n = 1245        for mot in mots:246            essai = (ligne + " " + mot).strip()247            if c.stringWidth(essai, "Helvetica", taille) <= largeur:248                ligne = essai249            else:250                n += 1251                ligne = mot252                if n >= max_lignes:253                    break254        return min(n, max_lignes)255256    def tuile(self, x: float, y: float, w: float, h: float,257              val: str, lab: str):258        """Tuile KPI blanche à ombre décalée (x, y = coin inférieur gauche)."""259        c = self.c260        c.setFillColor(INK)261        c.roundRect(x + 2.5, y - 2.5, w, h, 5, stroke=0, fill=1)262        c.setFillColor(SURFACE)263        c.setStrokeColor(INK)264        c.setLineWidth(1.2)265        c.roundRect(x, y, w, h, 5, stroke=1, fill=1)266        c.setFont("Helvetica-Bold", 12)267        c.setFillColor(INK)268        c.drawString(x + 8, y + h - 20, str(val))269        c.setFont("Courier", 6.2)270        c.setFillColor(INK3)271        c.drawString(x + 8, y + 7, lab.upper()[: max(4, int(w / 4.2))])272273274def _titre_droite(c, y, x, texte):275    c.setFillColor(INK)276    c.rect(x + 2, y - 1.5, 9, 9, stroke=0, fill=1)277    c.setFillColor(ACCENT)278    c.setStrokeColor(INK)279    c.setLineWidth(1)280    c.rect(x, y + 0.5, 9, 9, stroke=1, fill=1)281    c.setFont("Helvetica-Bold", 12.5)282    c.setFillColor(INK)283    c.drawString(x + 16, y, texte)284    c.setStrokeColor(ACCENT)285    c.setLineWidth(3)286    c.line(x + 16, y - 4, x + 16 + c.stringWidth(texte, "Helvetica-Bold", 12.5), y - 4)287    return y - 18288289290def _verdict_vraiprix(price, vp):291    """Pastille verdict identique au site : ±% vs estimation, masqué si292    |pct| > 60 (prix non comparable : terrain, loyer…)."""293    if not price or not vp or vp.get("value") in (None, 0):294        return None295    pct = round((price - vp["value"]) / vp["value"] * 100)296    if abs(pct) > 60:297        return None298    if pct >= 1:299        return (f"▲{NBSP}{pct}{NBSP}% au-dessus de l'estimation",300                RED_FG, RED_BG, ACCENT_DEEP)301    if pct <= -1:302        return (f"▼{NBSP}{abs(pct)}{NBSP}% sous l'estimation",303                GREEN_FG, GREEN_BG, GREEN_FG)304    return (f"≈{NBSP}Prix aligné sur l'estimation", INK2, SURFACE, INK)305306307def fiche_pdf(uid: str) -> bytes | None:308    """PDF de la fiche de la propriété `uid` — reflète tous les blocs de la309    fiche web ; None si introuvable."""310    con = db.connect()311    row = con.execute("SELECT * FROM listings WHERE uid=?", (uid,)).fetchone()312    if row is None:313        con.close()314        return None315    l = dict(row)316    l["features"] = json.loads(l.get("features") or "[]")317    l["images"] = json.loads(l.get("images") or "[]")318    l["details"] = json.loads(l.get("details") or "{}")319    try:320        l["vraiprix"] = json.loads(l["vraiprix"]) or None if l.get("vraiprix") else None321    except (ValueError, TypeError):322        l["vraiprix"] = None323    price_history = [dict(r) for r in con.execute(324        "SELECT ts, price FROM price_log WHERE uid=? ORDER BY ts DESC LIMIT 10",325        (uid,)).fetchall()]326    quartier = None327    lat, lng = l.get("lat"), l.get("lng")328    geo = lat is not None and lng is not None329    if geo:330        from . import quartier as qmod331        dauid = l.get("dauid")332        try:333            quartier = qmod.fiche_quartier(334                lat, lng, l.get("city") or "",335                dauid if dauid and dauid != "hors-zone" else None)336        except Exception:337            quartier = None338    try:339        reg = json.loads(340            (db.DB_PATH.parent / "sources.json").read_text("utf-8"))["sources"]341        src_name = next((s["name"] for s in reg if s["id"] == l["source"]),342                        l["source"])343    except Exception:344        src_name = l["source"]345    con.close()346347    # --- enrichissements (mêmes modules que les blocs de la fiche web) -------348    def _safe(fn, *a, **k):349        try:350            return fn(*a, **k)351        except Exception:352            return None353354    from . import air as air_mod355    from . import commerces as com_mod356    from . import gaz as gaz_mod357    from . import hydro as hydro_mod358    from . import inondation as inon_mod359    inon = _safe(inon_mod.lookup, lat, lng) if geo else None360    air_d = _safe(air_mod.lookup, lat, lng) if geo else None361    gaz_d = _safe(gaz_mod.nearby, lat, lng) if geo else None362    com = _safe(com_mod.nearby, lat, lng) if geo else None363    hyd = _safe(hydro_mod.estimate, adresse=l.get("address") or "",364                uid=uid, lat=lat, lng=lng, solve=False)365366    d = l["details"]367    vp = l.get("vraiprix")368    location = (d.get("transaction") == "location")369370    buf = io.BytesIO()371    c = rl_canvas.Canvas(buf, pagesize=letter)372    c.setTitle(f"Immo-Ka — {l.get('address') or l.get('title') or uid}")373    s = _Style(c)374    s.fond()375    y = s.entete("Fiche propriété")376    page = [1]377378    def saut_page() -> float:379        s.pied(_PIED_FICHE, page[0])380        c.showPage()381        page[0] += 1382        s.fond()383        return s.entete("Fiche propriété · suite")384385    def place(y_: float, besoin: float) -> float:386        """Saute de page si `besoin` points ne tiennent plus sous y_."""387        return saut_page() if (y_ - besoin) < M + 34 else y_388389    # --- héro : prix + pastille verdict Vrai-Prix -----------------------------390    prix_txt = (_fmt_money(l["price"]) + (f"{NBSP}/ mois" if location else "")) \391        if l.get("price") else (l.get("price_label") or "Prix sur demande")392    c.setFont("Helvetica-Bold", 30)393    c.setFillColor(INK)394    c.drawString(M, y - 24, prix_txt)395    bx = M + c.stringWidth(prix_txt, "Helvetica-Bold", 30) + 14396    verd = _verdict_vraiprix(l.get("price"), vp)397    if verd:398        txt, fg, bg, stk = verd399        bx = s.pilule(bx, y - 22, txt, fg, bg, stk)400    if l.get("property_type"):401        s.pilule(bx, y - 22, l["property_type"], ACCENT, INK)402    y -= 44403404    # --- titre + adresse -------------------------------------------------------405    titre = l.get("address") or l.get("title") or ""406    c.setFont("Helvetica-Bold", 13)407    c.setFillColor(INK)408    c.drawString(M, y, titre[:80])409    y -= 14410    loc = " · ".join(x for x in (l.get("sector"), l.get("city"), l.get("region"))411                     if x)412    c.setFont("Helvetica", 9)413    c.setFillColor(INK2)414    c.drawString(M, y, loc[:110])415    y -= 20416417    # --- chips clés (mêmes specs que le héro du site) --------------------------418    chips = []419    if l.get("bedrooms") is not None:420        chips.append(f"{l['bedrooms']}{NBSP}ch.")421    if l.get("bathrooms") is not None:422        chips.append(f"{l['bathrooms']}{NBSP}sdb")423    if l.get("powder_rooms"):424        chips.append(f"{l['powder_rooms']}{NBSP}s. d'eau")425    if l.get("area_sqft"):426        chips.append(f"{round(l['area_sqft']):,}".replace(",", NBSP) + f"{NBSP}pi²")427    if l.get("lot_sqft"):428        chips.append(f"Terrain {round(l['lot_sqft']):,}".replace(",", NBSP)429                     + f"{NBSP}pi²")430    if l.get("year_built"):431        chips.append(f"Construit en {l['year_built']}")432    if l.get("price") and l.get("area_sqft") and l["area_sqft"] > 200:433        chips.append(f"{round(l['price'] / l['area_sqft'])}{NBSP}$/pi²")434    if l.get("mls"):435        chips.append(f"MLS {l['mls']}")436    if l.get("first_seen"):437        dom = max(0, int((time.time() - l["first_seen"]) // 86400))438        chips.append(f"{dom}{NBSP}j sur Immo-Ka")439    x = M440    for chip in chips[:8]:441        x = s.pilule(x, y - 6, chip)442    y -= 30443444    # --- photos : 1 grande + 3 vignettes ---------------------------------------445    imgs = [im for im in (_fetch_image(u) for u in l["images"][:4]) if im]446    if imgs:447        big_h = 170448        big_w = big_h * 16 / 10.5449        c.setStrokeColor(INK)450        c.setLineWidth(1.5)451        try:452            c.drawImage(imgs[0], M, y - big_h, big_w, big_h,453                        preserveAspectRatio=True, anchor="c", mask="auto")454        except Exception:455            pass456        c.rect(M, y - big_h, big_w, big_h, stroke=1, fill=0)457        tx = M + big_w + 10458        th = (big_h - 16) / 3459        for i, im in enumerate(imgs[1:4]):460            ty = y - (i + 1) * th - i * 8461            try:462                c.drawImage(im, tx, ty, PAGE_W - M - tx, th,463                            preserveAspectRatio=True, anchor="c", mask="auto")464                c.rect(tx, ty, PAGE_W - M - tx, th, stroke=1, fill=0)465            except Exception:466                pass467        y -= big_h + 18468469    # --- baisse/hausse de prix (bandeau, comme le site) -------------------------470    ph = [p for p in price_history if p.get("price")]471    if len(ph) >= 2 and ph[0]["price"] != ph[1]["price"]:472        y = place(y, 34)473        de, a = ph[1]["price"], ph[0]["price"]474        bas = a < de475        c.setFillColor(GREEN_BG if bas else RED_BG)476        c.setStrokeColor(GREEN_FG if bas else RED_FG)477        c.setLineWidth(1.2)478        c.roundRect(M, y - 20, PAGE_W - 2 * M, 24, 5, stroke=1, fill=1)479        c.setFont("Helvetica-Bold", 9)480        c.setFillColor(GREEN_FG if bas else RED_FG)481        c.drawString(M + 10, y - 12,482                     f"{'▼' if bas else '▲'} Prix passé de {_fmt_money(de)} à "483                     f"{_fmt_money(a)} le {_fmt_jour(ph[0]['ts'])}")484        y -= 34485486    # --- description | inclusions (2 colonnes, comme le site) -------------------487    col_g = PAGE_W * 0.56 - M488    feats = [str(f) for f in (l.get("features") or []) if f]489    desc = (l.get("description") or "").strip()490    if desc or feats:491        besoin = 30 + max(492            s.nb_lignes(desc, col_g, max_lignes=26) * 11.5 if desc else 0,493            min(len(feats), 18) * 12)494        y = place(y, min(besoin, 340))495        y_desc = y496        if desc:497            y_desc = s.titre_section(y, "Description")498            y_desc = s.paragraphe(M, y_desc, desc, col_g, max_lignes=26)499        y_incl = y500        if feats:501            xr = M + col_g + 24502            y_incl = _titre_droite(c, y, xr, "Inclusions et caractéristiques")503            c.setFont("Helvetica", 8.5)504            for f in feats[:18]:505                c.setFillColor(ACCENT_DEEP)506                c.drawString(xr, y_incl, "✓")507                c.setFillColor(INK2)508                c.drawString(xr + 12, y_incl, f[:42])509                y_incl -= 12510            if len(feats) > 18:511                c.setFillColor(INK3)512                c.drawString(xr + 12, y_incl, f"+ {len(feats) - 18} autres…")513                y_incl -= 12514        y = min(y_desc, y_incl) - 16515516    # --- caractéristiques (details, mêmes exclusions que le site) ---------------517    dets = [(str(k), str(v)) for k, v in d.items()518            if k not in DETAIL_HIDDEN and v not in (None, "", [], {})519            and str(v).strip("-— ") != "" and not isinstance(v, (dict, list))]520    if dets:521        col_w = PAGE_W - 2 * M522        besoin = 40 + sum(523            s.nb_lignes(v, col_w - 190, taille=8, max_lignes=4) * 11 + 3524            for _, v in dets)525        y = place(y, min(besoin, 420))526        y = s.titre_section(y, "Caractéristiques")527        c.setFont("Courier-Bold", 7)528        c.setFillColor(INK3)529        c.drawString(M, y, "DÉTAIL")530        c.drawString(M + 180, y, "VALEUR")531        y -= 4532        c.setStrokeColor(INK)533        c.setLineWidth(1.5)534        c.line(M, y, PAGE_W - M, y)535        y -= 12536        for i, (k, v) in enumerate(dets):537            nl = s.nb_lignes(v, col_w - 190, taille=8, max_lignes=4)538            y = place(y, nl * 11 + 6)539            if i % 2 == 0:   # zébrage éditorial540                c.setFillColor(SURFACE)541                c.rect(M - 3, y - (nl - 1) * 11 - 3, col_w + 6, nl * 11 + 2,542                       stroke=0, fill=1)543            c.setFont("Helvetica-Bold", 8)544            c.setFillColor(INK)545            c.drawString(M, y, k[:38])546            y = s.paragraphe(M + 180, y, v, col_w - 190, taille=8,547                             interligne=11, max_lignes=4)548            y -= 3549        y -= 10550551    # --- pièces (details.pieces) -------------------------------------------------552    pieces = d.get("pieces") or []553    if isinstance(pieces, list) and pieces:554        rows = pieces[:24]555        y = place(y, 46 + len(rows) * 12)556        y = s.titre_section(y, "Pièces")557        c.setFont("Courier-Bold", 7)558        c.setFillColor(INK3)559        for lab, xoff in (("PIÈCE", 0), ("NIVEAU", 190), ("DIMENSIONS", 330),560                          ("REVÊTEMENT", 430)):561            c.drawString(M + xoff, y, lab)562        y -= 4563        c.setStrokeColor(INK)564        c.setLineWidth(1.5)565        c.line(M, y, PAGE_W - M, y)566        y -= 11567        for i, p in enumerate(rows):568            if i % 2 == 0:569                c.setFillColor(SURFACE)570                c.rect(M - 3, y - 3, PAGE_W - 2 * M + 6, 12, stroke=0, fill=1)571            c.setFont("Helvetica-Bold", 8)572            c.setFillColor(INK)573            c.drawString(M, y, str(p.get("nom") or "—")[:36])574            c.setFont("Helvetica", 8)575            c.setFillColor(INK2)576            c.drawString(M + 190, y, str(p.get("niveau") or "—")[:24])577            c.drawString(M + 330, y, str(p.get("dimensions") or "—")[:18])578            c.drawString(M + 430, y, str(p.get("revetement") or "—")[:22])579            y -= 12580        y -= 10581582    # --- analyse Vrai-Prix (valeur, jauge, rôle foncier) -------------------------583    if vp and vp.get("value") is not None:584        role = [(lab, vp.get(k)) for k, lab in585                (("valeur_role", "Évaluation au rôle"),586                 ("valeur_terrain", "Valeur du terrain"),587                 ("valeur_batiment", "Valeur du bâtiment"))588                if vp.get(k)]589        besoin = 96 + (56 if vp.get("low") is not None else 0) \590            + (62 if role else 0) + 18591        y = place(y, besoin)592        y = s.titre_section(y, "Analyse Vrai-Prix")593        val_txt = _fmt_money(round(vp["value"]))594        c.setFont("Helvetica-Bold", 17)595        c.setFillColor(INK)596        c.drawString(M, y - 10, val_txt)597        c.setFont("Helvetica", 8.5)598        c.setFillColor(INK2)599        c.drawString(M + c.stringWidth(val_txt, "Helvetica-Bold", 17) + 8,600                     y - 10, "— valeur marchande estimée par le modèle Vrai-Prix")601        meta = []602        if vp.get("confidence"):603            meta.append(f"confiance {vp['confidence']}"604                        + (f" ({vp['confidence_pct']}{NBSP}%)"605                           if vp.get("confidence_pct") else ""))606        if vp.get("annee_construction_role"):607            meta.append(f"construction {vp['annee_construction_role']} (rôle)")608        if vp.get("superficie_terrain_role_m2"):609            meta.append(610                f"terrain {round(vp['superficie_terrain_role_m2']):,}".replace(",", NBSP)611                + f"{NBSP}m² (rôle)")612        if meta:613            c.setFont("Helvetica", 8)614            c.setFillColor(INK3)615            c.drawString(M, y - 24, " · ".join(meta)[:115])616        verd = _verdict_vraiprix(l.get("price"), vp)617        if verd:618            txt, fg, bg, stk = verd619            s.pilule(M, y - 48, txt, fg, bg, stk)620        y -= 58621        lo, hi = vp.get("low"), vp.get("high")622        if lo is not None and hi is not None and hi > lo:623            larg = PAGE_W - 2 * M624            base = y - 22625            span = hi - lo626            c.setFillColor(ACCENT_SOFT)627            c.setStrokeColor(INK)628            c.setLineWidth(1)629            c.roundRect(M, base, larg, 12, 6, stroke=1, fill=1)630631            def _pos(v):632                return M + larg * max(0.02, min(0.98, (v - lo) / span))633634            px = _pos(vp["value"])635            c.setStrokeColor(ACCENT_DEEP)636            c.setLineWidth(2.5)637            c.line(px, base - 3, px, base + 15)638            c.setFont("Courier-Bold", 6.5)639            c.setFillColor(ACCENT_DEEP)640            c.drawString(min(max(px - 30, M), PAGE_W - M - 100), base + 18,641                         f"ESTIMATION · {_fmt_money(round(vp['value']))}")642            if l.get("price") and lo * 0.7 <= l["price"] <= hi * 1.3:643                ax = _pos(l["price"])644                c.setStrokeColor(INK)645                c.setLineWidth(2)646                c.line(ax, base - 3, ax, base + 15)647                c.setFont("Courier-Bold", 6.5)648                c.setFillColor(INK)649                c.drawString(min(max(ax - 30, M), PAGE_W - M - 116), base - 12,650                             f"PRIX DEMANDÉ · {_fmt_money(l['price'])}")651            c.setFont("Helvetica", 6.5)652            c.setFillColor(INK3)653            c.drawString(M, base - 24, _fmt_money(round(lo)))654            c.drawRightString(PAGE_W - M, base - 24,655                              _fmt_money(round(hi)) + " — fourchette d'estimation")656            y = base - 36657        if role:658            tw = (PAGE_W - 2 * M - (len(role) - 1) * 10) / len(role)659            for i, (lab, v) in enumerate(role):660                s.tuile(M + i * (tw + 10), y - 44, tw, 38,661                        _fmt_money(round(v)), lab)662            y -= 56663        c.setFont("Helvetica", 6.5)664        c.setFillColor(INK3)665        c.drawString(M, y - 2, "Estimation automatisée Vrai-Prix (vrai-prix.ca) "666                               "croisée au rôle d'évaluation foncière — ne "667                               "remplace pas une évaluation professionnelle.")668        y -= 16669670    # --- historique de prix -------------------------------------------------------671    if len(ph) >= 2:672        rows = ph[:8]673        y = place(y, 46 + len(rows) * 12)674        y = s.titre_section(y, "Historique du prix demandé")675        c.setFont("Courier-Bold", 7)676        c.setFillColor(INK3)677        for lab, xoff in (("DATE", 0), ("PRIX AFFICHÉ", 190), ("VARIATION", 330)):678            c.drawString(M + xoff, y, lab)679        y -= 4680        c.setStrokeColor(INK)681        c.setLineWidth(1.5)682        c.line(M, y, PAGE_W - M, y)683        y -= 11684        for i, p in enumerate(rows):685            prec = rows[i + 1]["price"] if i + 1 < len(rows) else None686            c.setFont("Helvetica", 8)687            c.setFillColor(INK2)688            c.drawString(M, y, _fmt_jour(p["ts"]))689            c.setFont("Helvetica-Bold", 8)690            c.setFillColor(INK)691            c.drawString(M + 190, y, _fmt_money(p["price"]))692            if prec and p.get("price"):693                delta = p["price"] - prec694                c.setFont("Courier-Bold", 7.5)695                c.setFillColor(GREEN_FG if delta < 0696                               else (RED_FG if delta > 0 else INK3))697                sig = "▼" if delta < 0 else ("▲" if delta > 0 else "=")698                c.drawString(M + 330, y,699                             f"{sig} {_fmt_money(abs(delta))}" if delta else "=")700            y -= 12701        y -= 10702703    # --- risque d'inondation (BDZI) ------------------------------------------------704    if inon:705        zones = (inon.get("zones") or [])[:3]706        y = place(y, 56 + len(zones) * 12)707        y = s.titre_section(y, "Risque d'inondation")708        statut = inon.get("statut")709        sev = {"eleve": "récurrence élevée", "modere": "récurrence modérée",710               "present": "zone cartographiée"}.get(inon.get("severite") or "", "")711        if statut == "en_zone":712            s.pilule(M, y - 12, f"En zone inondable ({sev})" if sev713                     else "En zone inondable", RED_FG, RED_BG, RED_FG)714        elif statut == "a_proximite":715            d0 = zones[0]["distance_m"] if zones else "?"716            s.pilule(M, y - 12, f"Zone inondable à ~{d0} m",717                     AMBER_FG, AMBER_BG, AMBER_FG)718        elif statut == "hors_zone":719            s.pilule(M, y - 12, "Hors des zones inondables cartographiées",720                     GREEN_FG, GREEN_BG, GREEN_FG)721        else:722            s.pilule(M, y - 12, "Secteur non couvert par la cartographie BDZI",723                     INK2, SURFACE, INK)724        y -= 26725        for z in zones:726            c.setFont("Helvetica", 8)727            c.setFillColor(INK2)728            c.drawString(M, y, f"{z.get('type') or 'Zone'} — à {z.get('distance_m')} m"729                               + (f" (rapport {z['date_rapport']})"730                                  if z.get("date_rapport") else ""))731            y -= 12732        y -= 8733734    # --- qualité de l'air (RSQAQ) ---------------------------------------------------735    if air_d and air_d.get("station"):736        mes = air_d.get("mesures") or {}737        y = place(y, 44 + len(mes) * 11)738        y = s.titre_section(y, "Qualité de l'air")739        c.setFont("Helvetica", 8.5)740        c.setFillColor(INK2)741        c.drawString(M, y - 2,742                     f"Station {air_d['station']} ({air_d['ville']}), à "743                     + f"{air_d['distance_km']}".replace(".", ",") + f"{NBSP}km "744                     "de la propriété — moyennes annuelles :")745        y -= 16746        for pol, m_ in list(mes.items())[:5]:747            c.setFont("Helvetica-Bold", 8)748            c.setFillColor(INK)749            c.drawString(M, y, pol)750            c.setFont("Helvetica", 8)751            c.setFillColor(INK2)752            val = (f"{m_['moyenne']:.1f}".replace(".", ",")753                   if m_.get("moyenne") is not None else "—")754            txt = f"{val}{NBSP}{m_.get('unite', '')} ({m_.get('annee', '')})"755            if m_.get("ref"):756                txt += f" · référence {m_.get('ref_nom', '')} : {m_['ref']}"757            c.drawString(M + 56, y, txt[:95])758            y -= 11759        y -= 8760761    # --- coût d'électricité (Hydro-Québec) -------------------------------------------762    if hyd and hyd.get("disponible"):763        y = place(y, 52)764        y = s.titre_section(y, "Coût d'électricité (Hydro-Québec)")765        c.setFont("Helvetica-Bold", 15)766        c.setFillColor(INK)767        mens = f"≈{NBSP}{_fmt_money(round(hyd['cout_mensuel']))} / mois"768        c.drawString(M, y - 8, mens)769        c.setFont("Helvetica", 8.5)770        c.setFillColor(INK2)771        suite = f"{_fmt_money(round(hyd['cout_annuel']))} / an"772        if hyd.get("kwh_annuel"):773            suite += (f" · {round(hyd['kwh_annuel']):,}".replace(",", NBSP)774                      + f"{NBSP}kWh/an")775        suite += " — estimation officielle Hydro-Québec pour cette adresse."776        c.drawString(M + c.stringWidth(mens, "Helvetica-Bold", 15) + 10, y - 8, suite)777        y -= 26778779    # --- commerces et transport --------------------------------------------------------780    if com and (com.get("commerces") or com.get("transit")):781        entrees = ([(t["commerce"], t["nom"], t["dist_m"])782                    for t in (com.get("transit") or [])[:4]]783                   + [(i["commerce"], i["nom"], i["dist_m"])784                      for i in (com.get("commerces") or [])[:12]])785        lignes = (len(entrees) + 1) // 2786        y = place(y, 36 + lignes * 13)787        y = s.titre_section(y, "Commerces et transport")788        col_w = (PAGE_W - 2 * M) / 2789        for i, (cat, nom, dist) in enumerate(entrees):790            px = M if i % 2 == 0 else M + col_w + 10791            py = y - (i // 2) * 13792            c.setFont("Helvetica", 8)793            c.setFillColor(INK2)794            nomc = nom if len(nom) <= 22 else nom[:21] + "…"795            c.drawString(px, py, f"{cat} — {nomc}")796            c.setFont("Courier-Bold", 7.5)797            c.setFillColor(INK)798            c.drawRightString(px + col_w - 14, py, _fmt_dist(dist))799        y -= lignes * 13 + 12800801    # --- essence à proximité --------------------------------------------------------------802    if gaz_d and gaz_d.get("stations"):803        sts = gaz_d["stations"][:3]804        y = place(y, 40 + len(sts) * 11)805        y = s.titre_section(y, "Essence à proximité")806        if gaz_d.get("mediane_regulier"):807            c.setFont("Helvetica", 8.5)808            c.setFillColor(INK2)809            c.drawString(M, y - 2, "Prix médian de l'essence régulière à <5 km : "810                         + f"{gaz_d['mediane_regulier']}".replace(".", ",")811                         + f"{NBSP}¢/L")812            y -= 14813        for st in sts:814            c.setFont("Helvetica", 8)815            c.setFillColor(INK2)816            reg_p = (f"{st['regulier']}".replace(".", ",") + f"{NBSP}¢/L"817                     if st.get("regulier") else "—")818            c.drawString(M, y, f"{st['nom']} — {_fmt_dist(st['dist_m'])} : {reg_p}"819                               + (" · la moins chère" if st.get("moins_chere") else ""))820            y -= 11821        y -= 8822823    # --- le quartier -------------------------------------------------------------------------824    if quartier and (quartier.get("demographie") or quartier.get("proximite")825                     or quartier.get("chaleur") or quartier.get("crime")):826        demo = quartier.get("demographie") or {}827        prox = quartier.get("proximite") or {}828        prox_aff = [(k, lab) for k, lab in PROX_LABELS829                    if isinstance(prox.get(k), (int, float))]830        besoin = 44 + (94 if demo else 0) + len(prox_aff) * 14 + 44831        y = place(y, besoin)832        y = s.titre_section(y, "Le quartier")833        c.setFont("Helvetica", 7.5)834        c.setFillColor(INK3)835        c.drawString(M, y, "Secteur immédiat de la propriété (aire de diffusion "836                           "du recensement 2021, ± 500 habitants).")837        y -= 14838        if demo:839            tuiles = [("Revenu médian", _fmt_money(demo.get("revenu_median"))),840                      ("Ménages locataires",841                       f"{round(demo['pct_locataires'])}{NBSP}%"842                       if demo.get("pct_locataires") is not None else "—"),843                      ("Loyer moyen secteur", _fmt_money(demo.get("loyer_moyen"))),844                      ("Âge médian",845                       f"{round(demo['age_median'])} ans"846                       if demo.get("age_median") is not None else "—"),847                      ("Français à la maison",848                       f"{round(demo['pct_francais'])}{NBSP}%"849                       if demo.get("pct_francais") is not None else "—"),850                      ("Diplôme universitaire",851                       f"{round(demo['pct_univ'])}{NBSP}%"852                       if demo.get("pct_univ") is not None else "—")]853            tw = (PAGE_W - 2 * M - 20) / 3854            for i, (lab, val) in enumerate(tuiles):855                s.tuile(M + (i % 3) * (tw + 10), y - 34 - (i // 3) * 44,856                        tw, 38, val, lab)857            y -= 34 + 44 + 16858        for k, lab in prox_aff:859            v = max(0.0, min(1.0, prox[k]))860            c.setFont("Helvetica", 8)861            c.setFillColor(INK2)862            c.drawString(M, y, lab)863            bx0, bw_ = M + 130, PAGE_W - 2 * M - 160864            c.setFillColor(SURFACE)865            c.setStrokeColor(INK)866            c.setLineWidth(0.8)867            c.roundRect(bx0, y - 1, bw_, 7, 3.5, stroke=1, fill=1)868            c.setFillColor(ACCENT)869            c.roundRect(bx0, y - 1, max(3, bw_ * v), 7, 3.5, stroke=0, fill=1)870            c.setFont("Courier-Bold", 7.5)871            c.setFillColor(INK)872            c.drawRightString(PAGE_W - M, y, str(round(v * 100)))873            y -= 14874        if prox_aff:875            c.setFont("Helvetica", 6.5)876            c.setFillColor(INK3)877            c.drawString(M, y, "Accessibilité 0–100 — mesures de proximité de "878                               "Statistique Canada.")879            y -= 12880        badges_q = []881        ch = quartier.get("chaleur")882        if ch:883            if ch.get("classe", 5) <= 3:884                badges_q.append(("Îlot de fraîcheur", GREEN_FG, GREEN_BG, GREEN_FG))885            elif ch.get("classe", 5) >= 7:886                ec = (f" (+{ch['ecart']:.1f} °C)".replace(".", ",")887                      if ch.get("ecart") is not None else "")888                badges_q.append((f"Îlot de chaleur{ec}",889                                 AMBER_FG, AMBER_BG, AMBER_FG))890        cr = quartier.get("crime")891        if cr and cr.get("type") == "igc" and cr.get("indice_canada"):892            dlt = round(100 * (cr["indice"] - cr["indice_canada"])893                        / cr["indice_canada"])894            badges_q.append((f"Criminalité {abs(dlt)}{NBSP}% "895                             f"{'sous' if dlt <= 0 else 'au-dessus de'} "896                             "la moyenne canadienne", INK2, SURFACE, INK))897        elif cr and cr.get("type") == "points":898            badges_q.append((f"{cr['douze_mois']} actes criminels à <500 m "899                             "(12 mois)", INK2, SURFACE, INK))900        if badges_q:901            x = M902            for txt, fg, bg, stk in badges_q:903                x = s.pilule(x, y - 8, txt, fg, bg, stk)904            y -= 26905        y -= 6906907    # --- références : QR + courtier + source ------------------------------------------------908    y = place(y, 140)909    y = s.titre_section(y, "Références")910    qr = qrcode.make(f"https://www.immo-ka.com/propriete/{uid}",911                     box_size=4, border=1)912    qb = io.BytesIO()913    qr.save(qb, format="PNG")914    c.drawImage(ImageReader(io.BytesIO(qb.getvalue())), M, y - 74, 70, 70)915    yy = y - 14916    c.setFont("Helvetica-Bold", 9)917    c.setFillColor(INK)918    courtier = l.get("broker_name") or d.get("Courtier inscripteur")919    if courtier:920        ligne = f"Courtier : {courtier}"921        if l.get("broker_phone"):922            ligne += f" · {l['broker_phone']}"923        c.drawString(M + 82, yy, ligne[:95])924        yy -= 13925    c.drawString(M + 82, yy, f"Agence : {src_name}")926    yy -= 14927    c.setFont("Helvetica", 8)928    c.setFillColor(INK2)929    c.drawString(M + 82, yy, "Annonce originale :")930    yy -= 11931    c.setFillColor(ACCENT_DEEP)932    c.drawString(M + 82, yy,933                 (l.get("url") or d.get("listing_origin_url") or "")[:90])934    yy -= 14935    c.setFillColor(INK2)936    c.drawString(M + 82, yy, "Fiche à jour en ligne (scannez le code) :")937    yy -= 11938    c.setFillColor(ACCENT_DEEP)939    c.drawString(M + 82, yy, f"www.immo-ka.com/propriete/{uid}")940    y -= 90941    s.paragraphe(M, y, "Sources : annonce de l'agence, Vrai-Prix (vrai-prix.ca), "942                       "rôle d'évaluation foncière, Statistique Canada (2021), "943                       "INSPQ (CC-BY 4.0), Ville de Montréal (CC-BY 4.0), BDZI, "944                       "RSQAQ, Hydro-Québec, gazquebec.ca, OpenStreetMap.",945                 PAGE_W - 2 * M, taille=6.5, couleur=INK3, interligne=9)946947    s.pied(_PIED_FICHE, page[0])948    c.save()949    return buf.getvalue()950