diff --git a/deploy/nginx-colibre.conf b/deploy/nginx-colibre.conf
new file mode 100644
index 0000000..bcb59d7
--- /dev/null
+++ b/deploy/nginx-colibre.conf
@@ -0,0 +1,85 @@
+# Configuration nginx de référence pour colibre.fr + migration decp.info.
+#
+# Installation (sur le serveur, en root) :
+# cp deploy/nginx-colibre.conf /etc/nginx/sites-available/colibre
+# ln -s /etc/nginx/sites-available/colibre /etc/nginx/sites-enabled/colibre
+# nginx -t && systemctl reload nginx
+#
+# Les certificats TLS sont gérés par certbot (Let's Encrypt) :
+# certbot --nginx -d colibre.fr -d www.colibre.fr -d decp.info -d www.decp.info
+# Obtenir/installer les certificats AVANT d'activer les redirections 301 :
+# une 301 vers un https cassé est mise en cache par les navigateurs/Google.
+
+# ---------------------------------------------------------------------------
+# 1. Migration decp.info → colibre.fr (301 permanentes, chemin préservé)
+# La structure d'URL est identique entre les deux domaines (rebranding),
+# donc $request_uri suffit : /acheteurs/123 → https://colibre.fr/acheteurs/123.
+# On NE redirige PAS vers la home : chaque ancienne URL garde son équivalent,
+# ce qui transfère l'autorité SEO page par page.
+# ---------------------------------------------------------------------------
+server {
+ listen 80;
+ listen [::]:80;
+ server_name decp.info www.decp.info;
+ # Laisser certbot répondre aux challenges ACME avant la redirection.
+ location /.well-known/acme-challenge/ { root /var/www/certbot; }
+ location / { return 301 https://colibre.fr$request_uri; }
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ server_name decp.info www.decp.info;
+
+ ssl_certificate /etc/letsencrypt/live/colibre.fr/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/colibre.fr/privkey.pem;
+
+ return 301 https://colibre.fr$request_uri;
+}
+
+# ---------------------------------------------------------------------------
+# 2. www.colibre.fr → colibre.fr (canonicalisation : un seul hôte indexé)
+# ---------------------------------------------------------------------------
+server {
+ listen 80;
+ listen [::]:80;
+ server_name colibre.fr www.colibre.fr;
+ location /.well-known/acme-challenge/ { root /var/www/certbot; }
+ location / { return 301 https://colibre.fr$request_uri; }
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ server_name www.colibre.fr;
+
+ ssl_certificate /etc/letsencrypt/live/colibre.fr/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/colibre.fr/privkey.pem;
+
+ return 301 https://colibre.fr$request_uri;
+}
+
+# ---------------------------------------------------------------------------
+# 3. Hôte canonique : https://colibre.fr → application Dash (gunicorn)
+# ---------------------------------------------------------------------------
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ server_name colibre.fr;
+
+ ssl_certificate /etc/letsencrypt/live/colibre.fr/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/colibre.fr/privkey.pem;
+
+ # Le sitemap acheteurs/titulaires peut être volumineux : autoriser de
+ # gros corps de réponse en proxy et des timeouts généreux.
+ proxy_read_timeout 120s;
+
+ location / {
+ # gunicorn app:server — adapter le bind à l'unit systemd colibre.
+ proxy_pass http://127.0.0.1:8000;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
diff --git a/src/app.py b/src/app.py
index 8c2e59f..f8db41b 100644
--- a/src/app.py
+++ b/src/app.py
@@ -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 = '\n'
- xml += '\n'
- for page in pages:
- xml += " \n"
- xml += f" {base_url}{page}\n"
- xml += " \n"
- xml += ""
- 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--.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 = """
{%css%}
-
+
+
+
{%app_entry%}
diff --git a/src/utils/sitemap.py b/src/utils/sitemap.py
new file mode 100644
index 0000000..3d4ebf4
--- /dev/null
+++ b/src/utils/sitemap.py
@@ -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" \n {escape(loc)}\n \n" for loc in locs)
+ return (
+ '\n'
+ '\n'
+ f"{body}"
+ ""
+ )
+
+
+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" \n {BASE_URL}{c}\n \n" for c in children
+ )
+ return (
+ '\n'
+ '\n'
+ f"{body}"
+ ""
+ )
+
+
+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])
diff --git a/tests/test_seo.py b/tests/test_seo.py
new file mode 100644
index 0000000..c1a12c0
--- /dev/null
+++ b/tests/test_seo.py
@@ -0,0 +1,68 @@
+"""Tests SEO : robots.txt, index de sitemaps, sous-sitemaps, canonical.
+
+Utilisent le test client Flask (pas de navigateur) : `src.app` initialise le
+cache au moment de l'import, donc les fonctions memoizées du sitemap marchent.
+Les données de test (tests/conftest.py) contiennent acheteur_id="123" et
+titulaire_id="345".
+"""
+
+import pytest
+
+
+@pytest.fixture(scope="module")
+def client():
+ from src.app import app
+
+ return app.server.test_client()
+
+
+def test_robots_declares_sitemap(client):
+ resp = client.get("/robots.txt")
+ assert resp.status_code == 200
+ body = resp.get_data(as_text=True)
+ assert "Sitemap: https://colibre.fr/sitemap.xml" in body
+
+
+def test_sitemap_index_lists_children(client):
+ resp = client.get("/sitemap.xml")
+ assert resp.status_code == 200
+ body = resp.get_data(as_text=True)
+ assert "https://colibre.fr/" in body
+ assert "https://colibre.fr/observatoire" in body
+
+
+def test_sitemap_acheteurs_lists_org_urls(client):
+ resp = client.get("/sitemap-acheteurs-1.xml")
+ assert resp.status_code == 200
+ assert "https://colibre.fr/acheteurs/123" in resp.get_data(as_text=True)
+
+
+def test_sitemap_titulaires_lists_org_urls(client):
+ resp = client.get("/sitemap-titulaires-1.xml")
+ assert resp.status_code == 200
+ assert "https://colibre.fr/titulaires/345" in resp.get_data(as_text=True)
+
+
+def test_sitemap_page_out_of_range_is_404(client):
+ assert client.get("/sitemap-acheteurs-99.xml").status_code == 404
+
+
+def test_sitemap_unknown_segment_is_404(client):
+ assert client.get("/sitemap-marches-1.xml").status_code == 404
+
+
+def test_index_has_canonical_link(client):
+ resp = client.get("/")
+ assert resp.status_code == 200
+ assert 'rel="canonical"' in resp.get_data(as_text=True)