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)