feat(seo): index de sitemaps acheteurs/titulaires, canonical, robots

- robots.txt déclare désormais le sitemap
- /sitemap.xml devient un index de sous-sitemaps paginés (limite 50k URLs/fichier)
  générés depuis DuckDB et mis en cache 24h : pages statiques, acheteurs, titulaires
- balise canonical auto-référente (JS, car index_string Dash partagé)
- config nginx de référence : 301 decp.info → colibre.fr (chemin préservé)
- tests SEO via test client Flask

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Colin Maudry
2026-06-30 23:06:46 +02:00
parent 9e8ad6c74b
commit 7184b7768a
4 changed files with 281 additions and 18 deletions
+29 -18
View File
@@ -126,28 +126,31 @@ roadmap_db.init_schema()
def robots():
text = """User-agent: *
Allow: /
"""
Sitemap: https://colibre.fr/sitemap.xml
"""
return Response(text, mimetype="text/plain")
# Index de sitemaps + sous-sitemaps paginés (voir src.utils.sitemap).
from src.utils import sitemap as _sitemap # noqa: E402 # src.db doit être prêt
@app.server.route("/sitemap.xml")
def sitemap():
base_url = "https://colibre.fr"
pages = [
"/",
"/observatoire",
"/tableau",
"/a-propos",
"/etapes",
]
xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
for page in pages:
xml += " <url>\n"
xml += f" <loc>{base_url}{page}</loc>\n"
xml += " </url>\n"
xml += "</urlset>"
return Response(xml, mimetype="text/xml")
return Response(_sitemap.build_index(), mimetype="application/xml")
@app.server.route("/sitemap-pages.xml")
def sitemap_pages():
return Response(_sitemap.build_pages(), mimetype="application/xml")
@app.server.route("/sitemap-<segment>-<int:page>.xml")
def sitemap_org(segment: str, page: int):
xml = _sitemap.build_org_page(segment, page)
if xml is None:
return Response("Not found", status=404)
return Response(xml, mimetype="application/xml")
@app.server.route("/llms.txt")
@@ -172,7 +175,15 @@ app.index_string = """
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16x16.png">
<link rel="manifest" href="/assets/icons/site.webmanifest">
{%css%}
<!-- canonical link -->
<!-- canonical auto-référent : l'index_string Dash est partagé par toutes
les pages, donc on pose le href côté client d'après l'URL courante
(sans query string). Google exécute le JS au rendu. -->
<link rel="canonical" id="canonical-link">
<script type="application/javascript">
document.getElementById('canonical-link').setAttribute(
'href', window.location.origin + window.location.pathname
);
</script>
</head>
<body>
{%app_entry%}
+99
View File
@@ -0,0 +1,99 @@
"""Génération du sitemap.xml.
Le sitemap est servi comme un **index** (`/sitemap.xml`) pointant vers des
sous-sitemaps paginés, car Google limite chaque fichier à 50 000 URLs / 50 Mo.
Les identifiants d'acheteurs (~30k) et de titulaires (~200k) sont lus depuis
DuckDB et mis en cache : la génération est trop coûteuse pour être refaite à
chaque requête de crawler.
"""
from xml.sax.saxutils import escape
from src.db import get_cursor
from src.utils.cache import cache
BASE_URL = "https://colibre.fr"
URLS_PER_SITEMAP = 50_000
# Pages statiques indexables.
STATIC_PAGES = [
"/",
"/observatoire",
"/tableau",
"/a-propos",
"/etapes",
]
# (segment d'URL, table DuckDB, colonne identifiant)
ORG_SITEMAPS = {
"acheteurs": ("acheteurs_departement", "acheteur_id"),
"titulaires": ("titulaires_departement", "titulaire_id"),
}
@cache.memoize(timeout=3600 * 24)
def _org_ids(table: str, id_col: str) -> list[str]:
"""Identifiants distincts non nuls d'une table d'organisations, triés."""
rows = (
get_cursor()
.execute(
f"SELECT DISTINCT {id_col} FROM {table} "
f"WHERE {id_col} IS NOT NULL ORDER BY {id_col}"
)
.fetchall()
)
return [str(r[0]) for r in rows]
def _chunk_count(n: int) -> int:
"""Nombre de sous-sitemaps nécessaires pour n URLs (au moins 1)."""
return max(1, -(-n // URLS_PER_SITEMAP))
def _urlset(locs: list[str]) -> str:
body = "".join(f" <url>\n <loc>{escape(loc)}</loc>\n </url>\n" for loc in locs)
return (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
f"{body}"
"</urlset>"
)
def build_index() -> str:
"""Index listant tous les sous-sitemaps."""
children = ["/sitemap-pages.xml"]
for segment, (table, id_col) in ORG_SITEMAPS.items():
pages = _chunk_count(len(_org_ids(table, id_col)))
children += [f"/sitemap-{segment}-{i}.xml" for i in range(1, pages + 1)]
body = "".join(
f" <sitemap>\n <loc>{BASE_URL}{c}</loc>\n </sitemap>\n" for c in children
)
return (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
f"{body}"
"</sitemapindex>"
)
def build_pages() -> str:
"""Sous-sitemap des pages statiques."""
return _urlset([f"{BASE_URL}{p}" for p in STATIC_PAGES])
def build_org_page(segment: str, page: int) -> str | None:
"""Sous-sitemap n° `page` (1-indexé) pour un type d'organisation.
Retourne None si le segment est inconnu ou la page hors limites.
"""
if segment not in ORG_SITEMAPS:
return None
table, id_col = ORG_SITEMAPS[segment]
ids = _org_ids(table, id_col)
if page < 1 or (page - 1) * URLS_PER_SITEMAP >= max(1, len(ids)):
return None
start = (page - 1) * URLS_PER_SITEMAP
chunk = ids[start : start + URLS_PER_SITEMAP]
return _urlset([f"{BASE_URL}/{segment}/{org_id}" for org_id in chunk])