An unlisted page at /secret. Nothing in the app links to it, it has no nav tab, and MobileNav's route-parity test now names it as an explicit exception so an ordinary page still fails that test. It IS crawlable and is advertised in the sitemap: "hidden" here means no button, not no index — a long, specific mechanics page is worth the search traffic. Every number on it was read out of the source or produced by RUNNING the real function. A grimoire of plausible-sounding numbers would be worse than none, because it would be believed. Four hand-rolled SVG figures (rarity by moon phase, the passage ladder, the two twist curves) rather than a charting dependency for four static pictures. The best thing in it is a trap I did not know existed until I ran the code: crossing over requires volatility strictly above 0.6, and favor shifts volatility DOWN by up to 0.12 at mint. So a hunter at maximum favor pulls spirits under the gate and locks themselves out of the largest reward in the game — 25 essence and +0.08 favor. Verified directly: a spirit at volatility 0.65 is crossable at favor 0.0, and is not at +0.5 or +1.0. "Do not maximise favor" is real, counter-intuitive, and follows from the source. English only, as agreed — written as prose outside the i18n system rather than several hundred translation keys, which keeps the en/es parity gate meaningful for the actual UI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
113 lines
4.0 KiB
Python
113 lines
4.0 KiB
Python
"""robots.txt and a live sitemap.
|
|
|
|
Served from the backend rather than dropped in `public/` because the
|
|
valuable, indexable surface of this site is the Codex, and the Codex grows
|
|
every time somebody summons. A static sitemap would be stale within an
|
|
hour; this one is generated from the actual entity rows.
|
|
|
|
What is deliberately NOT listed: /seance, /enter, /profile, /log,
|
|
/inventory, /devices — anything per-seeker or interactive. A crawler
|
|
hitting /seance would provision a guest account on arrival (the open
|
|
door), which would fill the users table with wanderers that never
|
|
existed as people. robots.txt disallows those paths for the same reason,
|
|
and the sitemap only advertises pages that are genuinely public,
|
|
stable, and worth a search result: the landing page, the shop, and every
|
|
discovered spirit.
|
|
"""
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
from fastapi import APIRouter, Depends, Response
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.config import settings
|
|
from app.db import get_db
|
|
from app.models.entity import Entity
|
|
|
|
router = APIRouter(tags=["seo"])
|
|
|
|
# Cap the sitemap so a runaway Codex can't produce a multi-megabyte
|
|
# document. 5k is far inside the 50k/50MB sitemap limit and orders of
|
|
# magnitude beyond what this install will realistically hold.
|
|
MAX_SITEMAP_ENTITIES = 5000
|
|
|
|
# Paths that must never be crawled: they either mutate state (provisioning a
|
|
# guest) or are meaningless without a session.
|
|
DISALLOWED = (
|
|
"/seance",
|
|
"/enter",
|
|
"/profile",
|
|
"/hunters",
|
|
"/log",
|
|
"/inventory",
|
|
"/devices",
|
|
"/api/",
|
|
)
|
|
|
|
|
|
def _base_url() -> str:
|
|
return settings.public_base_url.rstrip("/")
|
|
|
|
|
|
def _iso(dt: datetime | None) -> str:
|
|
value = dt or datetime.now(timezone.utc)
|
|
if value.tzinfo is None:
|
|
value = value.replace(tzinfo=timezone.utc)
|
|
return value.date().isoformat()
|
|
|
|
|
|
def _xml_escape(text: str) -> str:
|
|
return (
|
|
text.replace("&", "&")
|
|
.replace("<", "<")
|
|
.replace(">", ">")
|
|
.replace('"', """)
|
|
)
|
|
|
|
|
|
@router.get("/robots.txt", include_in_schema=False)
|
|
async def robots() -> Response:
|
|
lines = ["User-agent: *"]
|
|
lines += [f"Disallow: {path}" for path in DISALLOWED]
|
|
lines.append("Allow: /")
|
|
lines.append(f"Sitemap: {_base_url()}/sitemap.xml")
|
|
return Response("\n".join(lines) + "\n", media_type="text/plain")
|
|
|
|
|
|
@router.get("/sitemap.xml", include_in_schema=False)
|
|
async def sitemap(db: AsyncSession = Depends(get_db)) -> Response:
|
|
base = _base_url()
|
|
result = await db.execute(
|
|
select(Entity.id, Entity.discovered_at)
|
|
.order_by(Entity.discovered_at.desc())
|
|
.limit(MAX_SITEMAP_ENTITIES)
|
|
)
|
|
entities = result.all()
|
|
|
|
parts = [
|
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
|
f"<url><loc>{base}/</loc><changefreq>daily</changefreq>"
|
|
"<priority>1.0</priority></url>",
|
|
f"<url><loc>{base}/codex</loc><changefreq>hourly</changefreq>"
|
|
"<priority>0.9</priority></url>",
|
|
f"<url><loc>{base}/shop</loc><changefreq>weekly</changefreq>"
|
|
"<priority>0.7</priority></url>",
|
|
# /secret is UNLISTED, not private: nothing in the app links to it and
|
|
# it has no nav tab, so a seeker only finds it by being told. It is
|
|
# advertised here anyway because it is a long, specific mechanics page
|
|
# and search traffic is the whole reason it is crawlable — "hidden"
|
|
# here means "no button", not "no index".
|
|
f"<url><loc>{base}/secret</loc><changefreq>monthly</changefreq>"
|
|
"<priority>0.8</priority></url>",
|
|
]
|
|
for entity_id, discovered_at in entities:
|
|
loc = _xml_escape(f"{base}/codex/{entity_id}")
|
|
parts.append(
|
|
f"<url><loc>{loc}</loc><lastmod>{_iso(discovered_at)}</lastmod>"
|
|
"<changefreq>weekly</changefreq><priority>0.6</priority></url>"
|
|
)
|
|
parts.append("</urlset>")
|
|
return Response("\n".join(parts), media_type="application/xml")
|