Python 68.8%
TypeScript 18.6%
CSS 8.7%
JavaScript 3.3%
HTML 0.6%
1# -----------------------------------------------------------------------------2# Rent-Ka — Rental listings aggregator (Canada, outside Québec)3# Author: Simon-Pierre Boucher — contact@spboucher.ai4# seo.py : search-engine optimization — light SSR, programmatic pages,5# robots.txt, sitemaps6#7# Principle: for every public route the server returns the SAME index.html as8# the Vite build, but with a unique <head> (title, description, canonical,9# og:, JSON-LD) and the essential content as HTML INSIDE <div id="root">.10# Search engines see a full page without executing JavaScript; React, on11# mount, replaces that content with the interactive application.12# -----------------------------------------------------------------------------13from __future__ import annotations1415import html16import json17import math18import re19import statistics20import time21import unicodedata22from datetime import date, datetime, timezone23from pathlib import Path24from xml.sax.saxutils import escape as xml_escape2526from fastapi import APIRouter, HTTPException27from fastapi.responses import HTMLResponse, PlainTextResponse, Response2829from . import db3031router = APIRouter()3233ROOT = Path(__file__).resolve().parent.parent34FRONTEND_DIST = ROOT / "frontend" / "dist"35SOURCES_PATH = ROOT / "data" / "sources.json"3637BASE_URL = "https://www.rent-ka.com"38SITE_NAME = "Rent-Ka"3940# monthly-rent plausibility bounds — out of bounds: price excluded from the41# statistics and structured data (some sources publish $0)42PRICE_MIN, PRICE_MAX = 195, 1500043PRICE_OK = f"price >= {PRICE_MIN} AND price <= {PRICE_MAX}"4445# inclusion threshold for a programmatic page in the sitemap46MIN_LISTINGS_PAGE = 347SITEMAP_CHUNK = 100004849PROVINCE_NAMES = {50 "ON": "Ontario", "BC": "British Columbia", "AB": "Alberta",51 "SK": "Saskatchewan", "MB": "Manitoba", "NB": "New Brunswick",52 "NS": "Nova Scotia", "PE": "Prince Edward Island",53 "NL": "Newfoundland and Labrador", "YT": "Yukon",54 "NT": "Northwest Territories", "NU": "Nunavut",55}565758# --- Slugs -------------------------------------------------------------------5960def slugify(text: str) -> str:61 """«Trois-Rivières» → trois-rivieres, «2 bedrooms» → 2-bedrooms,62 «5+ bedrooms» → 5-plus-bedrooms."""63 t = text.replace("½", "-1-2").replace("+", "-plus")64 t = t.replace("œ", "oe").replace("Œ", "Oe").replace("æ", "ae").replace("Æ", "Ae")65 t = unicodedata.normalize("NFKD", t).encode("ascii", "ignore").decode()66 t = re.sub(r"[^a-z0-9]+", "-", t.lower()).strip("-")67 return t686970_maps_cache: dict = {"ts": 0.0, "cities": {}, "types": {}}717273def _slug_maps() -> tuple[dict[str, str], dict[str, str]]:74 """(slug→city, slug→unit type), rebuilt at most every 10 minutes."""75 if time.time() - _maps_cache["ts"] > 600:76 con = db.connect()77 cities = [r["city"] for r in con.execute(78 "SELECT DISTINCT city FROM listings WHERE active=1 AND city<>''")]79 types = [r["unit_type"] for r in con.execute(80 "SELECT DISTINCT unit_type FROM listings WHERE active=1 AND unit_type<>''")]81 con.close()82 _maps_cache["cities"] = {slugify(c): c for c in sorted(cities)}83 _maps_cache["types"] = {slugify(t): t for t in sorted(types)}84 _maps_cache["ts"] = time.time()85 return _maps_cache["cities"], _maps_cache["types"]868788# --- Template (index.html from the Vite build) ---------------------------------8990_shell_cache: dict = {"mtime": 0.0, "html": ""}919293def _shell() -> str:94 f = FRONTEND_DIST / "index.html"95 mtime = f.stat().st_mtime96 if mtime != _shell_cache["mtime"]:97 _shell_cache["html"] = f.read_text(encoding="utf-8")98 _shell_cache["mtime"] = mtime99 return _shell_cache["html"]100101102def _render(*, title: str, description: str, path: str, jsonld: list[dict] | None = None,103 body: str = "", og_image: str | None = None, status: int = 200) -> HTMLResponse:104 """Build index.html + unique head + HTML content inside #root."""105 canonical = BASE_URL + path106 page = _shell()107 page = re.sub(r"<title>.*?</title>",108 lambda _m: f"<title>{html.escape(title)}</title>", page, count=1, flags=re.S)109 page = re.sub(r'<meta name="description"[^>]*/>',110 lambda _m: f'<meta name="description" content="{html.escape(description, quote=True)}" />',111 page, count=1)112 # strip static og:image/twitter metas from the template (re-injected below)113 page = re.sub(r'\s*<meta (?:property="og:image[^"]*"|name="twitter:(?:card|image)")[^>]*/>', "", page)114 extras = [115 f'<link rel="canonical" href="{canonical}" />',116 f'<link rel="alternate" hreflang="en-ca" href="{canonical}" />',117 f'<link rel="alternate" hreflang="x-default" href="{canonical}" />',118 f'<meta property="og:site_name" content="{SITE_NAME}" />',119 '<meta property="og:locale" content="en_CA" />',120 '<meta property="og:type" content="website" />',121 f'<meta property="og:title" content="{html.escape(title, quote=True)}" />',122 f'<meta property="og:description" content="{html.escape(description, quote=True)}" />',123 f'<meta property="og:url" content="{canonical}" />',124 '<meta name="twitter:card" content="summary_large_image" />',125 f'<meta name="twitter:title" content="{html.escape(title, quote=True)}" />',126 ]127 img = og_image or (BASE_URL + "/og.png")128 extras.append(f'<meta property="og:image" content="{html.escape(img, quote=True)}" />')129 if not og_image:130 extras.append('<meta property="og:image:width" content="1200" />')131 extras.append('<meta property="og:image:height" content="630" />')132 extras.append(f'<meta name="twitter:image" content="{html.escape(img, quote=True)}" />')133 for obj in (jsonld or []):134 blob = json.dumps(obj, ensure_ascii=False).replace("</", "<\\/")135 extras.append(f'<script type="application/ld+json">{blob}</script>')136 page = page.replace("</head>", " " + "\n ".join(extras) + "\n</head>", 1)137 if body:138 seo_div = ('<div style="max-width:960px;margin:0 auto;padding:24px;'139 'font-family:system-ui,sans-serif;color:#141814">' + body140 + '<p>Rent-Ka — A <a href="https://www.groupe-ka.com">Groupe KA</a> service</p>'141 + "</div>")142 page = page.replace('<div id="root">', '<div id="root">' + seo_div, 1)143 return HTMLResponse(page, status_code=status,144 headers={"Cache-Control": "no-cache"})145146147def _e(t) -> str:148 return html.escape(str(t or ""))149150151def _fmt_price(p) -> str:152 return f"${int(round(p)):,}" if p else ""153154155def _price_ok(p) -> bool:156 return p is not None and PRICE_MIN <= p <= PRICE_MAX157158159def _iso(ts) -> str:160 if not ts:161 return date.today().isoformat()162 return datetime.fromtimestamp(ts, tz=timezone.utc).date().isoformat()163164165def _listing_li(r) -> str:166 """One listing in a server-rendered HTML list."""167 label = r["title"] or r["address"] or r["uid"]168 bits = [b for b in (r["unit_type"], _fmt_price(r["price"]) + "/month" if _price_ok(r["price"]) else "",169 r["sector"] or r["city"]) if b]170 return (f'<li><a href="/listing/{_e(r["uid"])}">{_e(label)}</a>'171 f'{" — " + _e(" · ".join(bits)) if bits else ""}</li>')172173174def _not_found(message: str, path: str) -> HTMLResponse:175 """HTML 404: the React shell is served (the SPA shows its page), but the176 status code and server content clearly say «not found» to bots."""177 return _render(title="Page not found | Rent-Ka",178 description="This page does not exist on Rent-Ka.",179 path=path,180 body=f"<h1>{_e(message)}</h1>"181 '<p><a href="/">Browse all rentals across Canada</a> · '182 '<a href="/cities">Rentals by city</a></p>',183 status=404)184185186def _breadcrumb(items: list[tuple[str, str]]) -> dict:187 return {"@context": "https://schema.org", "@type": "BreadcrumbList",188 "itemListElement": [189 {"@type": "ListItem", "position": i + 1, "name": name,190 "item": BASE_URL + path}191 for i, (name, path) in enumerate(items)]}192193194# --- Data ----------------------------------------------------------------------195196def _city_stats(con, city: str) -> dict:197 n = con.execute("SELECT COUNT(*) c FROM listings WHERE active=1 AND city=?",198 (city,)).fetchone()["c"]199 prices = [r["price"] for r in con.execute(200 f"SELECT price FROM listings WHERE active=1 AND city=? AND {PRICE_OK}", (city,))]201 types = [dict(r) for r in con.execute(202 """SELECT unit_type, COUNT(*) n FROM listings203 WHERE active=1 AND city=? AND unit_type<>''204 GROUP BY unit_type ORDER BY n DESC""", (city,))]205 for t in types:206 t["slug"] = slugify(t["unit_type"])207 return {"n": n,208 "avg": round(statistics.mean(prices)) if prices else None,209 "med": round(statistics.median(prices)) if prices else None,210 "types": types}211212213def _cities_rows(con, minimum: int = 1) -> list[dict]:214 rows = [dict(r) for r in con.execute(215 f"""SELECT city, province, COUNT(*) n,216 AVG(CASE WHEN {PRICE_OK} THEN price END) avg_price,217 MAX(updated_at) last218 FROM listings WHERE active=1 AND city<>''219 GROUP BY city HAVING n>=? ORDER BY n DESC""", (minimum,))]220 for r in rows:221 r["slug"] = slugify(r["city"])222 r["avg_price"] = round(r["avg_price"]) if r["avg_price"] else None223 return rows224225226def _parse_row(r) -> dict:227 d = dict(r)228 for k in ("amenities", "images"):229 d[k] = json.loads(d.get(k) or "[]")230 d["details"] = json.loads(d.get("details") or "{}")231 return d232233234# --- JSON API for the frontend city pages ---------------------------------------235236@router.get("/api/seo/cities")237def api_cities():238 con = db.connect()239 rows = _cities_rows(con)240 con.close()241 return {"cities": rows}242243244@router.get("/api/seo/city/{slug}")245def api_city(slug: str, type: str | None = None):246 cities, types = _slug_maps()247 city = cities.get(slug)248 if not city:249 raise HTTPException(404, "Unknown city")250 unit_type = None251 if type:252 unit_type = types.get(type)253 if not unit_type:254 raise HTTPException(404, "Unknown unit type")255 con = db.connect()256 stats = _city_stats(con, city)257 sql = "SELECT * FROM listings WHERE active=1 AND city=?"258 args: list = [city]259 if unit_type:260 sql += " AND unit_type=?"261 args.append(unit_type)262 listings = [_parse_row(r) for r in con.execute(263 sql + " ORDER BY updated_at DESC LIMIT 100", args)]264 neighbors = [v for v in _cities_rows(con, MIN_LISTINGS_PAGE) if v["city"] != city][:12]265 con.close()266 return {"city": city, "slug": slug, "unit_type": unit_type,267 **stats, "listings": listings, "neighbors": neighbors}268269270# --- robots.txt & sitemaps -------------------------------------------------------271272@router.get("/robots.txt", include_in_schema=False)273def robots() -> PlainTextResponse:274 return PlainTextResponse(275 "User-agent: *\n"276 "Allow: /\n"277 "Disallow: /api/\n"278 "Disallow: /uploads/\n"279 "Disallow: /profile\n"280 "Disallow: /favorites\n"281 "Disallow: /manage\n"282 "Disallow: /welcome\n"283 "Disallow: /bot\n"284 "Disallow: /gateway/\n"285 f"\nSitemap: {BASE_URL}/sitemap.xml\n")286287288def _xml(content: str) -> Response:289 return Response('<?xml version="1.0" encoding="UTF-8"?>\n' + content,290 media_type="application/xml",291 headers={"Cache-Control": "public, max-age=3600"})292293294def _urlset(urls: list[tuple[str, str | None]]) -> Response:295 rows = []296 for loc, lastmod in urls:297 lm = f"<lastmod>{lastmod}</lastmod>" if lastmod else ""298 rows.append(f"<url><loc>{xml_escape(loc)}</loc>{lm}</url>")299 return _xml('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'300 + "\n".join(rows) + "\n</urlset>")301302303@router.get("/sitemap.xml", include_in_schema=False)304def sitemap_index():305 con = db.connect()306 total = con.execute("SELECT COUNT(*) c FROM listings WHERE active=1").fetchone()["c"]307 con.close()308 chunks = max(1, math.ceil(total / SITEMAP_CHUNK))309 names = (["sitemap-pages.xml", "sitemap-cities.xml"]310 + [f"sitemap-listings-{i}.xml" for i in range(1, chunks + 1)])311 today = date.today().isoformat()312 rows = "\n".join(313 f"<sitemap><loc>{BASE_URL}/{n}</loc><lastmod>{today}</lastmod></sitemap>"314 for n in names)315 return _xml('<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'316 + rows + "\n</sitemapindex>")317318319@router.get("/sitemap-pages.xml", include_in_schema=False)320def sitemap_pages():321 urls: list[tuple[str, str | None]] = [322 (f"{BASE_URL}/", None), (f"{BASE_URL}/cities", None),323 (f"{BASE_URL}/stats", None), (f"{BASE_URL}/sources", None),324 (f"{BASE_URL}/privacy", None), (f"{BASE_URL}/terms", None)]325 con = db.connect()326 for r in con.execute(327 """SELECT source, MAX(updated_at) last FROM listings328 WHERE active=1 GROUP BY source"""):329 urls.append((f"{BASE_URL}/g/{r['source']}", _iso(r["last"])))330 con.close()331 return _urlset(urls)332333334@router.get("/sitemap-cities.xml", include_in_schema=False)335def sitemap_cities():336 con = db.connect()337 urls: list[tuple[str, str | None]] = []338 for v in _cities_rows(con, MIN_LISTINGS_PAGE):339 urls.append((f"{BASE_URL}/city/{v['slug']}", _iso(v["last"])))340 for r in con.execute(341 f"""SELECT city, unit_type, COUNT(*) n, MAX(updated_at) last342 FROM listings WHERE active=1 AND city<>'' AND unit_type<>''343 GROUP BY city, unit_type HAVING n>=?""", (MIN_LISTINGS_PAGE,)):344 urls.append((f"{BASE_URL}/city/{slugify(r['city'])}/{slugify(r['unit_type'])}",345 _iso(r["last"])))346 con.close()347 return _urlset(urls)348349350@router.get("/sitemap-listings-{num}.xml", include_in_schema=False)351def sitemap_listings(num: int):352 if num < 1:353 raise HTTPException(404)354 con = db.connect()355 rows = con.execute(356 """SELECT uid, updated_at FROM listings WHERE active=1357 ORDER BY uid LIMIT ? OFFSET ?""",358 (SITEMAP_CHUNK, (num - 1) * SITEMAP_CHUNK)).fetchall()359 con.close()360 if not rows:361 raise HTTPException(404)362 return _urlset([(f"{BASE_URL}/listing/{r['uid']}", _iso(r["updated_at"]))363 for r in rows])364365366# --- SSR pages -------------------------------------------------------------------367368@router.get("/", include_in_schema=False)369def home_ssr():370 con = db.connect()371 total = con.execute("SELECT COUNT(*) c FROM listings WHERE active=1").fetchone()["c"]372 nsources = con.execute(373 "SELECT COUNT(DISTINCT source) c FROM listings WHERE active=1").fetchone()["c"]374 cities = _cities_rows(con, MIN_LISTINGS_PAGE)375 provinces = [dict(r) for r in con.execute(376 """SELECT province, COUNT(*) n FROM listings377 WHERE active=1 AND published=1 AND province<>''378 GROUP BY province ORDER BY n DESC""")]379 recents = con.execute(380 f"""SELECT uid, title, address, sector, city, unit_type, price FROM listings381 WHERE active=1 AND {PRICE_OK} ORDER BY first_seen DESC LIMIT 20""").fetchall()382 con.close()383384 title = f"Rent-Ka — A Groupe KA service · {total:,} rentals across Canada"385 description = (f"{total:,} apartments and homes for rent across Canada "386 f"(outside Québec), aggregated from {nsources} property "387 "managers and always up to date: Toronto, Ottawa, Vancouver, "388 "Calgary, Halifax and more. Photos, prices, availability and "389 "a direct link to the original listing.")390 top = cities[:30]391 body = (392 f"<h1>Apartments for rent across Canada — {total:,} live listings</h1>"393 + f"<p>Rent-Ka aggregates the rentals published by {nsources} property "394 "managers across Canada (outside Québec): every listing with its "395 "photos, price, availability and a direct link to the manager's "396 "website.</p>"397 + "<h2>Rentals by province</h2><ul>"398 + "".join(f"<li>{_e(PROVINCE_NAMES.get(p['province'], p['province']))}"399 f" — {p['n']} listings</li>" for p in provinces)400 + "</ul><h2>Rentals by city</h2><ul>"401 + "".join(f'<li><a href="/city/{v["slug"]}">Apartments for rent in {_e(v["city"])}</a>'402 f' — {v["n"]} listings'403 + (f", average rent {_fmt_price(v['avg_price'])}" if v["avg_price"] else "")404 + "</li>" for v in top)405 + f'</ul><p><a href="/cities">All cities ({len(cities)})</a></p>'406 + "<h2>Latest listings</h2><ul>"407 + "".join(_listing_li(r) for r in recents)408 + "</ul>")409 jsonld = [410 {"@context": "https://schema.org", "@type": "WebSite",411 "name": SITE_NAME, "url": BASE_URL + "/",412 "description": description, "inLanguage": "en-CA"},413 {"@context": "https://schema.org", "@type": "Organization",414 "name": "Rent-Ka (Groupe KA)", "url": BASE_URL + "/"}]415 return _render(title=title, description=description, path="/",416 jsonld=jsonld, body=body)417418419@router.get("/cities", include_in_schema=False)420def cities_ssr():421 con = db.connect()422 cities = _cities_rows(con)423 con.close()424 total = sum(v["n"] for v in cities)425 title = f"Rentals by city across Canada ({len(cities)} cities) | Rent-Ka"426 description = (f"Every Canadian city where Rent-Ka tracks rentals: "427 f"{total:,} listings in {len(cities)} cities, with the "428 "average rent and the number of available apartments per city.")429 body = (f"<h1>Rentals by city — {len(cities)} cities across Canada</h1><ul>"430 + "".join(f'<li><a href="/city/{v["slug"]}">{_e(v["city"])}</a>'431 + (f" ({_e(v['province'])})" if v.get("province") else "")432 + f' — {v["n"]} listings'433 + (f", average rent {_fmt_price(v['avg_price'])}" if v["avg_price"] else "")434 + "</li>" for v in cities)435 + "</ul>")436 jsonld = [_breadcrumb([("Home", "/"), ("Cities", "/cities")]),437 {"@context": "https://schema.org", "@type": "ItemList",438 "name": "Rentals by city across Canada",439 "numberOfItems": len(cities),440 "itemListElement": [441 {"@type": "ListItem", "position": i + 1,442 "name": f"Apartments for rent in {v['city']}",443 "url": f"{BASE_URL}/city/{v['slug']}"}444 for i, v in enumerate(cities[:100])]}]445 return _render(title=title, description=description, path="/cities",446 jsonld=jsonld, body=body)447448449def _city_ssr(slug: str, type_slug: str | None = None):450 cities, types = _slug_maps()451 path = f"/city/{slug}" + (f"/{type_slug}" if type_slug else "")452 city = cities.get(slug)453 if not city:454 return _not_found("No rentals tracked for this city", path)455 unit_type = None456 if type_slug is not None:457 unit_type = types.get(type_slug)458 if not unit_type:459 return _not_found("Unknown unit type", path)460461 con = db.connect()462 stats = _city_stats(con, city)463 sql = "SELECT * FROM listings WHERE active=1 AND city=?"464 args: list = [city]465 if unit_type:466 sql += " AND unit_type=?"467 args.append(unit_type)468 n = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]469 prices = [r["price"] for r in con.execute(470 f"SELECT price FROM listings WHERE active=1 AND city=? AND unit_type=? AND {PRICE_OK}",471 (city, unit_type))]472 avg = round(statistics.mean(prices)) if prices else None473 med = round(statistics.median(prices)) if prices else None474 else:475 n, avg, med = stats["n"], stats["avg"], stats["med"]476 rows = con.execute(sql + " ORDER BY updated_at DESC LIMIT 100", args).fetchall()477 neighbors = [v for v in _cities_rows(con, MIN_LISTINGS_PAGE) if v["city"] != city][:12]478 con.close()479 if n == 0:480 return _not_found(f"No active listing in {city} for this type", path)481482 what = f"{unit_type} rentals" if unit_type else "Apartments for rent"483 title = f"{what} in {city} — {n} listings | Rent-Ka"484 desc_stats = (f"average rent {_fmt_price(avg)}, median {_fmt_price(med)}"485 if avg and med else "")486 description = (f"{n} {what.lower()} in {city}"487 + (f" ({desc_stats})" if desc_stats else "")488 + ". Up-to-date listings from property managers, with photos, "489 "prices, availability and a direct link to the original listing.")490491 body = [f"<h1>{_e(what)} in {_e(city)} — {n} listings</h1>"]492 if avg and med:493 body.append(f"<p>Average rent: <strong>{_fmt_price(avg)}/month</strong> · "494 f"median rent: <strong>{_fmt_price(med)}/month</strong>.</p>")495 if not unit_type and stats["types"]:496 body.append("<h2>By unit type</h2><ul>" + "".join(497 f'<li><a href="/city/{slug}/{t["slug"]}">{_e(t["unit_type"])} for rent in '498 f'{_e(city)}</a> — {t["n"]} listings</li>' for t in stats["types"]) + "</ul>")499 body.append("<h2>Listings</h2><ul>"500 + "".join(_listing_li(r) for r in rows) + "</ul>")501 if unit_type:502 body.append(f'<p><a href="/city/{slug}">All rentals in {_e(city)}</a></p>')503 body.append("<h2>Other cities</h2><ul>" + "".join(504 f'<li><a href="/city/{v["slug"]}">Apartments for rent in {_e(v["city"])}</a>'505 f' — {v["n"]}</li>' for v in neighbors) + "</ul>")506507 crumbs = [("Home", "/"), ("Cities", "/cities"), (city, f"/city/{slug}")]508 if unit_type:509 crumbs.append((f"{unit_type} in {city}", path))510 jsonld = [_breadcrumb(crumbs),511 {"@context": "https://schema.org", "@type": "ItemList",512 "name": f"{what} in {city}", "numberOfItems": n,513 "itemListElement": [514 {"@type": "ListItem", "position": i + 1,515 "name": r["title"] or r["address"] or r["uid"],516 "url": f"{BASE_URL}/listing/{r['uid']}"}517 for i, r in enumerate(rows[:50])]}]518 return _render(title=title, description=description, path=path,519 jsonld=jsonld, body="".join(body))520521522@router.get("/city/{slug}", include_in_schema=False)523def city_ssr(slug: str):524 return _city_ssr(slug)525526527@router.get("/city/{slug}/{type_slug}", include_in_schema=False)528def city_type_ssr(slug: str, type_slug: str):529 return _city_ssr(slug, type_slug)530531532@router.get("/listing/{uid:path}", include_in_schema=False)533def listing_ssr(uid: str):534 con = db.connect()535 row = con.execute("SELECT * FROM listings WHERE uid=?", (uid,)).fetchone()536 con.close()537 if row is None:538 return _render(title="Listing not found | Rent-Ka",539 description="This listing does not or no longer exists on Rent-Ka.",540 path=f"/listing/{uid}",541 body="<h1>Listing not found</h1>"542 '<p><a href="/">Browse all rentals across Canada</a></p>',543 status=404)544 d = _parse_row(row)545 city_slug = slugify(d["city"]) if d["city"] else ""546 if not d["active"]:547 # listing removed at the source: 410 Gone + link to the parent city548 link = (f'<a href="/city/{city_slug}">Apartments for rent in {_e(d["city"])}</a>'549 if city_slug else '<a href="/">All rentals</a>')550 return _render(551 title="Listing removed | Rent-Ka",552 description="This listing was removed by the property manager.",553 path=f"/listing/{uid}",554 body=f"<h1>This listing is no longer available</h1>"555 f"<p>It was removed by the manager. {link}.</p>",556 status=410)557558 label = d["title"] or d["address"] or "Rental unit"559 where = d["city"] if d["city"] and d["city"] not in label else ""560 title = (f"{d['unit_type'] + ' for rent — ' if d['unit_type'] else ''}{label}"561 + (f", {where}" if where else "") + " | Rent-Ka")562 price_txt = f"{_fmt_price(d['price'])}/month" if _price_ok(d["price"]) else (d["price_label"] or "")563 bits = [b for b in (d["unit_type"], price_txt, d["sector"], d["city"],564 d["availability"]) if b]565 description = (" · ".join(bits) + ". " if bits else "") + \566 (d["description"][:150].strip() + "…" if len(d["description"] or "") > 150567 else (d["description"] or "")).strip()568 description = description[:300] or f"Rental listing in {d['city']} on Rent-Ka."569570 body = [f"<h1>{_e(label)}{' — ' + _e(d['unit_type']) if d['unit_type'] else ''}</h1>"]571 facts = [("Address", d["address"]), ("City", d["city"]),572 ("Neighbourhood", d["sector"]),573 ("Type", d["unit_type"]), ("Rent", price_txt),574 ("Availability", d["availability"]),575 ("Area", f"{int(d['area_sqft'])} sq ft" if d["area_sqft"] else "")]576 body.append("<ul>" + "".join(f"<li><strong>{k}</strong>: {_e(v)}</li>"577 for k, v in facts if v) + "</ul>")578 if d["description"]:579 body.append(f"<p>{_e(d['description'][:600])}</p>")580 if d["amenities"]:581 body.append("<p><strong>Amenities</strong>: "582 + _e(", ".join(d["amenities"][:15])) + "</p>")583 if d["url"]:584 body.append(f'<p><a href="{_e(d["url"])}" rel="nofollow">'585 "See the original listing at the manager's site</a></p>")586 if city_slug:587 body.append(f'<p><a href="/city/{city_slug}">Other rentals in '588 f'{_e(d["city"])}</a></p>')589590 beds = None591 m = re.match(r"(\d+)", d["unit_type"] or "")592 if m:593 beds = int(m.group(1))594 apartment: dict = {595 "@type": "Apartment", "name": label,596 "address": {"@type": "PostalAddress",597 "streetAddress": d["address"] or None,598 "addressLocality": d["city"] or None,599 "addressRegion": d.get("province") or "ON",600 "addressCountry": "CA"}}601 if d["lat"] and d["lng"]:602 apartment["geo"] = {"@type": "GeoCoordinates",603 "latitude": d["lat"], "longitude": d["lng"]}604 if beds:605 apartment["numberOfBedrooms"] = beds606 if d["area_sqft"]:607 apartment["floorSize"] = {"@type": "QuantitativeValue",608 "value": d["area_sqft"], "unitCode": "FTK"}609 if d["images"]:610 apartment["photo"] = d["images"][:5]611 listing_ld: dict = {612 "@context": "https://schema.org", "@type": "RealEstateListing",613 "name": title.removesuffix(" | Rent-Ka"),614 "url": f"{BASE_URL}/listing/{uid}",615 "datePosted": _iso(d["first_seen"]), "inLanguage": "en-CA",616 "about": apartment}617 if _price_ok(d["price"]):618 listing_ld["offers"] = {619 "@type": "Offer", "price": d["price"], "priceCurrency": "CAD",620 "availability": "https://schema.org/InStock",621 "businessFunction": "http://purl.org/goodrelations/v1#LeaseOut"}622 crumbs = [("Home", "/")]623 if city_slug:624 crumbs.append((d["city"], f"/city/{city_slug}"))625 crumbs.append((label, f"/listing/{uid}"))626 return _render(title=title, description=description, path=f"/listing/{uid}",627 jsonld=[listing_ld, _breadcrumb(crumbs)], body="".join(body),628 og_image=d["images"][0] if d["images"] else None)629630631@router.get("/g/{source_id}", include_in_schema=False)632def manager_ssr(source_id: str):633 registry = {s["id"]: s for s in634 json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]}635 src = registry.get(source_id)636 if not src:637 return _not_found("Unknown manager", f"/g/{source_id}")638 con = db.connect()639 n = con.execute("SELECT COUNT(*) c FROM listings WHERE active=1 AND source=?",640 (source_id,)).fetchone()["c"]641 rows = con.execute(642 """SELECT uid, title, address, sector, city, unit_type, price FROM listings643 WHERE active=1 AND source=? ORDER BY updated_at DESC LIMIT 60""",644 (source_id,)).fetchall()645 con.close()646 name = src["name"]647 title = f"{name} — {n} rentals | Rent-Ka"648 description = (f"The {n} rentals from {name}"649 + (f" ({src['region']})" if src.get("region") else "")650 + " tracked by Rent-Ka, with prices, photos and a direct "651 "link to the original listing.")652 body = (f"<h1>{_e(name)} — {n} rentals</h1>"653 + (f"<p>Areas: {_e(src['sectors'])}.</p>" if src.get("sectors") else "")654 + "<ul>" + "".join(_listing_li(r) for r in rows) + "</ul>"655 + '<p><a href="/sources">All property managers</a></p>')656 jsonld = [_breadcrumb([("Home", "/"), ("Sources", "/sources"),657 (name, f"/g/{source_id}")]),658 {"@context": "https://schema.org", "@type": "Organization",659 "name": name, "url": src.get("url") or f"{BASE_URL}/g/{source_id}"}]660 return _render(title=title, description=description, path=f"/g/{source_id}",661 jsonld=jsonld, body=body)662663664# static app pages: unique head, content rendered by React665_STATIC_META = {666 "/stats": ("Canadian rental market statistics | Rent-Ka",667 "Average and median rents, breakdowns by city and unit type: "668 "the Canadian rental market statistics, computed continuously "669 "by Rent-Ka."),670 "/sources": ("Aggregated sources — property managers and portals | Rent-Ka",671 "Every source aggregated by Rent-Ka: Canadian property "672 "managers and rental portals, with each one's number of "673 "active listings."),674 "/privacy": ("Privacy policy | Rent-Ka",675 "Rent-Ka's privacy policy: collected data, cookies and "676 "user rights."),677 "/terms": ("Terms of use | Rent-Ka",678 "Terms of use of the Rent-Ka service, an independent rental "679 "listings aggregator covering Canada outside Québec."),680}681682683def _static_page(path: str):684 title, description = _STATIC_META[path]685 return _render(title=title, description=description, path=path)686687688@router.get("/stats", include_in_schema=False)689def stats_page():690 return _static_page("/stats")691692693@router.get("/sources", include_in_schema=False)694def sources_page():695 return _static_page("/sources")696697698@router.get("/privacy", include_in_schema=False)699def privacy_page():700 return _static_page("/privacy")701702703@router.get("/terms", include_in_schema=False)704def terms_page():705 return _static_page("/terms")706