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:
@@ -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;
|
||||
}
|
||||
}
|
||||
+28
-17
@@ -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%}
|
||||
|
||||
@@ -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])
|
||||
@@ -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 "<sitemapindex" in body
|
||||
assert "https://colibre.fr/sitemap-pages.xml" in body
|
||||
assert "https://colibre.fr/sitemap-acheteurs-1.xml" in body
|
||||
assert "https://colibre.fr/sitemap-titulaires-1.xml" in body
|
||||
|
||||
|
||||
def test_sitemap_pages_lists_static_pages(client):
|
||||
resp = client.get("/sitemap-pages.xml")
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_data(as_text=True)
|
||||
assert "<urlset" in body
|
||||
assert "<loc>https://colibre.fr/</loc>" 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)
|
||||
Reference in New Issue
Block a user