SPB Git forge

spb/rent-ka

Public
8commits 1branches 0releases
7.4 MBsize
maindefault branch
19 days agolast push
Python 68.8% TypeScript 18.6% CSS 8.7% JavaScript 3.3% HTML 0.6%
30.5 KB · 706 lines python
Raw Blame History
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