Compare commits

2 Commits

Author SHA1 Message Date
JingleManSweep
59d0edc96f Merge pull request #76 from ipnet-mesh/chore/add-dynamic-sitemap-xml
Added dynamic XML sitemap for SEO
2026-02-06 12:53:20 +00:00
Louis King
b01611e0e8 Added dynamic XML sitemap for SEO 2026-02-06 12:50:40 +00:00
2 changed files with 75 additions and 3 deletions

View File

@@ -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

View File

@@ -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" <url>\n"
f" <loc>{base_url}{path}</loc>\n"
f" <changefreq>{changefreq}</changefreq>\n"
f" <priority>{priority}</priority>\n"
f" </url>"
)
# 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" <url>\n"
f" <loc>{base_url}/nodes/{public_key[:8]}</loc>\n"
f" <changefreq>daily</changefreq>\n"
f" <priority>0.5</priority>\n"
f" </url>"
)
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 = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
+ "\n".join(urls)
+ "\n</urlset>"
)
return Response(content=xml, media_type="application/xml")
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(