Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)
Python 47.5%
HTML 27.9%
TypeScript 15.5%
CSS 7.2%
JavaScript 2%
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