From b01611e0e89cf840e4675f0d1982e2e1759df0d1 Mon Sep 17 00:00:00 2001 From: Louis King Date: Fri, 6 Feb 2026 12:50:40 +0000 Subject: [PATCH] Added dynamic XML sitemap for SEO --- src/meshcore_hub/api/routes/nodes.py | 11 +++++ src/meshcore_hub/web/app.py | 67 ++++++++++++++++++++++++++-- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/meshcore_hub/api/routes/nodes.py b/src/meshcore_hub/api/routes/nodes.py index a46f211..9969336 100644 --- a/src/meshcore_hub/api/routes/nodes.py +++ b/src/meshcore_hub/api/routes/nodes.py @@ -23,6 +23,7 @@ async def list_nodes( ), adv_type: Optional[str] = Query(None, description="Filter by advertisement type"), member_id: Optional[str] = Query(None, description="Filter by member_id tag value"), + role: Optional[str] = Query(None, description="Filter by role tag value"), limit: int = Query(50, ge=1, le=500, description="Page size"), offset: int = Query(0, ge=0, description="Page offset"), ) -> NodeList: @@ -59,6 +60,16 @@ async def list_nodes( ) ) + if role: + # Filter nodes that have a role tag with the specified value + query = query.where( + Node.id.in_( + select(NodeTag.node_id).where( + NodeTag.key == "role", NodeTag.value == role + ) + ) + ) + # Get total count count_query = select(func.count()).select_from(query.subquery()) total = session.execute(count_query).scalar() or 0 diff --git a/src/meshcore_hub/web/app.py b/src/meshcore_hub/web/app.py index fae3505..dc3752d 100644 --- a/src/meshcore_hub/web/app.py +++ b/src/meshcore_hub/web/app.py @@ -7,7 +7,7 @@ from typing import AsyncGenerator import httpx from fastapi import FastAPI, Request -from fastapi.responses import HTMLResponse, PlainTextResponse +from fastapi.responses import HTMLResponse, PlainTextResponse, Response from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from starlette.exceptions import HTTPException as StarletteHTTPException @@ -153,9 +153,70 @@ def create_app( return {"status": "not_ready", "api": str(e)} @app.get("/robots.txt", response_class=PlainTextResponse) - async def robots_txt() -> str: + async def robots_txt(request: Request) -> str: """Serve robots.txt to control search engine crawling.""" - return "User-agent: *\nAllow: /\n" + base_url = str(request.base_url).rstrip("/") + return f"User-agent: *\nAllow: /\n\nSitemap: {base_url}/sitemap.xml\n" + + @app.get("/sitemap.xml") + async def sitemap_xml(request: Request) -> Response: + """Generate dynamic sitemap including all node pages.""" + base_url = str(request.base_url).rstrip("/") + + # Static pages + static_pages = [ + ("", "daily", "1.0"), + ("/network", "hourly", "0.9"), + ("/nodes", "hourly", "0.9"), + ("/advertisements", "hourly", "0.8"), + ("/messages", "hourly", "0.8"), + ("/map", "daily", "0.7"), + ("/members", "weekly", "0.6"), + ] + + urls = [] + for path, changefreq, priority in static_pages: + urls.append( + f" \n" + f" {base_url}{path}\n" + f" {changefreq}\n" + f" {priority}\n" + f" " + ) + + # Fetch infrastructure nodes for dynamic pages + try: + response = await request.app.state.http_client.get( + "/api/v1/nodes", params={"limit": 500, "role": "infra"} + ) + if response.status_code == 200: + nodes = response.json().get("items", []) + for node in nodes: + public_key = node.get("public_key") + if public_key: + # Use 8-char prefix (route handles redirect to full key) + urls.append( + f" \n" + f" {base_url}/nodes/{public_key[:8]}\n" + f" daily\n" + f" 0.5\n" + f" " + ) + else: + logger.warning( + f"Failed to fetch nodes for sitemap: {response.status_code}" + ) + except Exception as e: + logger.warning(f"Failed to fetch nodes for sitemap: {e}") + + xml = ( + '\n' + '\n' + + "\n".join(urls) + + "\n" + ) + + return Response(content=xml, media_type="application/xml") @app.exception_handler(StarletteHTTPException) async def http_exception_handler(