Merge branch 'feature/66_seo' into dev

This commit is contained in:
Colin Maudry
2026-01-28 14:06:03 +01:00
13 changed files with 356 additions and 38 deletions
+4
View File
@@ -1,3 +1,7 @@
#### 2.5.0 ()
- Renforcement du SEO avec une arborescence permettant l'accès aux marchés et des snippets JSON-LD
##### 2.4.1 (22 janvier 2026)
- Meilleure gestion des colonnes absentes du schéma
+7 -1
View File
@@ -13,6 +13,10 @@ development = os.getenv("DEVELOPMENT").lower() == "true"
meta_tags = [
{"name": "viewport", "content": "width=device-width, initial-scale=1"},
{
"name": "keywords",
"content": "commande publique, decp, marchés publics, données essentielles",
},
]
if development:
@@ -25,6 +29,7 @@ app = Dash(
compress=True,
meta_tags=meta_tags,
)
# COSMO (belle font, blue),
# UNITED (rouge, ubuntu font),
# LUMEN (gros séparateur, blue clair),
@@ -142,7 +147,8 @@ navbar = dbc.Navbar(
)
)
for page in page_registry.values()
if page["name"] not in ["Acheteur", "Titulaire", "Marché"]
if page["name"]
in ["Recherche", "À propos", "Tableau", "Statistiques"]
],
className="ms-auto",
navbar=True,
+2 -1
View File
@@ -254,7 +254,8 @@ summary > h3 {
}
}
/* CSS for the /a-propos page Layout and Table of Contents */
/* CSS for the /a-propos page Layout and Table of Contents
Généré par Gemini 3.0 Pro */
.a-propos-container {
display: flex;
+10 -2
View File
@@ -169,14 +169,22 @@ def get_sources_tables(source_path) -> html.Div:
+ pl.lit("</a>")
).alias("nom")
)
df = df.drop("url")
df = df.drop("url", "unique")
df = df.sort(by=["nb_marchés"], descending=True)
columns = {
"nom": "Nom de la source",
"organisation": "Responsable de publication",
"nb_marchés": "Nb de marchés",
"nb_acheteurs": "Nb d'acheteurs",
"code": "Code",
}
datatable = dash_table.DataTable(
id="source_table",
columns=[
{
"name": i,
"name": columns[i],
"id": i,
"presentation": "markdown",
"type": "text",
+8
View File
@@ -122,6 +122,14 @@ Rien dexceptionnel, je respecte simplement la loi, qui dit que certains outil
Jutilise pour cela [Matomo](https://matomo.org/), un outil [libre](https://matomo.org/free-software/), paramétré pour être en conformité avec [la recommandation « Cookies »](https://www.cnil.fr/fr/solutions-pour-les-cookies-de-mesure-daudience) de la CNIL. Cela signifie que votre adresse IP, par exemple, est anonymisée avant d’être enregistrée. Il mest donc impossible dassocier vos visites sur ce site à votre personne."""
),
html.H4(
"Liste des marchés par département", id="liste_marches"
),
dcc.Markdown(
"""
- [Marchés par département](/departements)
"""
),
],
),
# Table of Contents Column
+7 -1
View File
@@ -7,6 +7,7 @@ from src.callbacks import get_top_org_table
from src.figures import DataTable, point_on_map
from src.utils import (
df,
df_acheteurs,
filter_table_data,
format_number,
get_annuaire_data,
@@ -20,7 +21,12 @@ from src.utils import (
def get_title(acheteur_id: str = None) -> str:
return f"Acheteur {acheteur_id} | decp.info"
acheteur_nom = (
df_acheteurs.filter(pl.col("acheteur_id") == acheteur_id)
.select("acheteur_nom")
.item()
)
return f"Marchés publics attribués par {acheteur_nom} | decp.info"
register_page(
+78
View File
@@ -0,0 +1,78 @@
import polars as pl
from dash import Input, Output, callback, dcc, html, register_page
from src.utils import departements, df_acheteurs_departement, df_titulaires_departement
name = "Département"
def get_title(code):
return f"Marchés publics de {departements[code]['departement']} | decp.info"
def get_description(code):
return f"Marchés publics passés dans le département {departements[code]['departement']} | decp.info"
register_page(
__name__,
path_template="/departements/<code>",
title=get_title,
description=get_description,
order=50,
name=name,
)
layout = html.Div(
[
dcc.Location(id="departement_url", refresh="callback-nav"),
html.Div(id="departement_marches"),
]
)
@callback(
Output(component_id="departement_marches", component_property="children"),
Input(component_id="departement_url", component_property="pathname"),
)
def departement_marches(url):
departement = url.split("/")[-1]
def make_link_list(org_type) -> list:
link_list = []
if org_type == "acheteur":
df = df_acheteurs_departement
elif org_type == "titulaire":
df = df_titulaires_departement
else:
raise ValueError
df = df.filter(pl.col(f"{org_type}_departement_code") == departement)
for row in df.iter_rows(named=True):
li = html.Li(
[
dcc.Link(
row[f"{org_type}_nom"],
href=url + f"/{org_type}/{row[f'{org_type}_id']}",
title=f"Marchés publics de {row[f'{org_type}_nom']}",
),
" ",
dcc.Link(
"(page dédiée)",
href=f"/{org_type}s/{row[f'{org_type}_id']}",
title=f"Page dédiée aux marchés publics de {row[f'{org_type}_nom']}",
),
]
)
link_list.append(li)
return link_list
content = [
html.H3("Acheteurs publics du département"),
html.Ul(make_link_list("acheteur")),
html.H3("Titulaires du département"),
html.Ul(make_link_list("titulaire")),
]
return content
+25
View File
@@ -0,0 +1,25 @@
from dash import dcc, html, register_page
from src.utils import departements
name = "Départements"
register_page(
__name__,
path="/departements",
title="Marchés par département | decp.info",
name="Départements",
description="Tous les marchés publics, classés par départements",
)
layout = html.Div(
[
html.H3("Départements"),
html.Ul(
[
html.Li(dcc.Link(d["departement"], href=f"/departements/{k}"))
for k, d in departements.items()
]
),
]
)
+99
View File
@@ -0,0 +1,99 @@
import polars as pl
from dash import Input, Output, callback, dcc, html, register_page
from src.utils import (
df_acheteurs,
df_acheteurs_marches,
df_titulaires,
df_titulaires_marches,
)
name = "Liste des marchés publics"
def make_org_nom_verbe(org_type, org_id) -> tuple:
if org_type == "titulaire":
df = df_titulaires
verbe = "remportés"
elif org_type == "acheteur":
df = df_acheteurs
verbe = "attribués"
else:
raise ValueError
org_nom = (
df.filter(pl.col(f"{org_type}_id") == org_id).select(f"{org_type}_nom").item()
)
return org_nom, verbe
def get_title(code, org_type, org_id):
org_nom, verbe = make_org_nom_verbe(org_type, org_id)
return f"Marchés publics {verbe} par {org_nom} | decp.info"
def get_description(code, org_type, org_id):
org_nom, verbe = make_org_nom_verbe(org_type, org_id)
return f"Liste complète des marchés publics {verbe} par {org_nom} et publiés par decp.info. Cliquez sur les liens pour consulter les détails de chaque marché."
register_page(
__name__,
path_template="/departements/<code>/<org_type>/<org_id>",
title=get_title,
description=get_description,
order=40,
name=name,
)
layout = html.Div(
[
dcc.Location(id="liste_marches_url", refresh="callback-nav"),
html.Div(id="liste_marches"),
]
)
@callback(
Output(component_id="liste_marches", component_property="children"),
Input(component_id="liste_marches_url", component_property="pathname"),
)
def liste_marches(url):
org_type = url.split("/")[-2]
org_id = url.split("/")[-1]
def make_link_list() -> list:
link_list = []
if org_type == "acheteur":
df = df_acheteurs_marches
elif org_type == "titulaire":
df = df_titulaires_marches
else:
raise ValueError
df = df.filter(pl.col(f"{org_type}_id") == org_id)
for row in df.iter_rows(named=True):
li = html.Li(
[
dcc.Link(
row["objet"],
href=f"/marches/{row['uid']}",
title=f"Marchés public attribué : {row['objet']}",
)
]
)
link_list.append(li)
return link_list
nom, verbe = make_org_nom_verbe(org_type, org_id)
content = [
html.H3(f"Marchés publics {verbe} par {nom}"),
html.Ul(make_link_list()),
]
return content
+5 -6
View File
@@ -34,9 +34,7 @@ layout = [
dcc.Store(id="marche_data"),
dcc.Store(id="titulaires_data"),
dcc.Location(id="marche_url", refresh="callback-nav"),
html.Script(
type="application/ld+json", id="marche_jsonld", children=['{"test": "1"}']
),
html.Script(type="application/ld+json", id="marche_jsonld"),
dbc.Container(
className="marche_infos",
children=[
@@ -114,7 +112,7 @@ def get_marche_data(url) -> tuple[dict, list]:
Input("titulaires_data", "data"),
)
def update_marche_info(marche, titulaires):
def make_parameter(col):
def make_parameter(col, bold=True):
column_object = data_schema.get(col)
column_name = column_object.get("title") if column_object else col
@@ -158,10 +156,11 @@ def update_marche_info(marche, titulaires):
else:
value = ""
param_content = html.P([column_name, " : ", html.Strong(value)])
value = html.Strong(value) if bold else value
param_content = html.P([column_name, " : ", value])
return param_content
marche_objet = make_parameter("objet")
marche_objet = make_parameter("objet", bold=False)
marche_infos = [
make_parameter("id"),
+60 -2
View File
@@ -18,8 +18,9 @@ from src.utils import (
)
from utils import prepare_table_data
update_date = os.path.getmtime(os.getenv("DATA_FILE_PARQUET_PATH"))
update_date = datetime.fromtimestamp(update_date).strftime("%d/%m/%Y")
update_date_timestamp = os.path.getmtime(os.getenv("DATA_FILE_PARQUET_PATH"))
update_date = datetime.fromtimestamp(update_date_timestamp).strftime("%d/%m/%Y")
update_date_iso = datetime.fromtimestamp(update_date_timestamp).isoformat()
name = "Tableau"
@@ -48,6 +49,63 @@ datatable = html.Div(
layout = [
dcc.Location(id="tableau_url", refresh=False),
html.Script(
type="application/ld+json",
id="dataset_jsonld",
children=[
json.dumps(
{
"@context": "https://schema.org/",
"@type": "Dataset",
"name": "Données essentielles des marchés publics français (DECP)",
"description": "Données de marchés publics exhaustives décrivant les marchés publics attribués en France depuis 2018.",
"url": "https://decp.info",
"sameAs": "https://www.data.gouv.fr/datasets/608c055b35eb4e6ee20eb325",
"keywords": [
"marchés publics",
"commande publique",
"decp",
"public procurement",
],
"license": "https://www.etalab.gouv.fr/licence-ouverte-open-licence",
"isAccessibleForFree": True,
"creator": {
"@type": "Organization",
"url": "https://colmo.tech",
"name": "Colmo",
"sameAs": "https://annuaire-entreprises.data.gouv.fr/entreprise/colmo-989393350",
"contactPoint": {
"@type": "ContactPoint",
"contactType": "Support et contact commercial",
"email": "colin@colmo.tech",
},
},
"includedInDataCatalog": {
"@type": "DataCatalog",
"name": "data.gouv.fr",
},
"distribution": [
{
"@type": "DataDownload",
"encodingFormat": "CSV",
"contentUrl": "https://www.data.gouv.fr/api/1/datasets/r/22847056-61df-452d-837d-8b8ceadbfc52",
},
{
"@type": "DataDownload",
"encodingFormat": "Parquet",
"contentUrl": "https://www.data.gouv.fr/api/1/datasets/r/11cea8e8-df3e-4ed1-932b-781e2635e432",
},
],
"temporalCoverage": f"2018-01-01/{update_date_iso[:10]}",
"spatialCoverage": {
"@type": "Place",
"address": {"countryCode": "FR"},
},
},
indent=2,
)
],
),
html.Div(
html.Details(
children=[
+7 -1
View File
@@ -7,6 +7,7 @@ from src.callbacks import get_top_org_table
from src.figures import DataTable, point_on_map
from src.utils import (
df,
df_titulaires,
filter_table_data,
format_number,
get_annuaire_data,
@@ -20,7 +21,12 @@ from src.utils import (
def get_title(titulaire_id: str = None) -> str:
return f"Titulaire {titulaire_id} | decp.info"
titulaire_nom = (
df_titulaires.filter(pl.col("titulaire_id") == titulaire_id)
.select("titulaire_nom")
.item()
)
return f"Marchés publics remportés par {titulaire_nom} | decp.info"
register_page(
+44 -24
View File
@@ -145,11 +145,12 @@ def format_number(number) -> str:
return number
def unformat_montant(number: str) -> int:
def unformat_montant(number: str) -> float:
number = number.replace("", "")
number = number.replace("", "").replace(" ", "")
number = number.replace(",", ".")
number = number.strip()
return int(number)
return float(number)
def format_values(dff: pl.DataFrame) -> pl.DataFrame:
@@ -701,27 +702,6 @@ def invert_columns(columns):
return inverted_columns
df: pl.DataFrame = get_decp_data()
schema = df.collect_schema()
df_acheteurs = get_org_data(df, "acheteur")
df_titulaires = get_org_data(df, "titulaire")
departements = get_departements()
domain_name = (
"test.decp.info" if os.getenv("DEVELOPMENT").lower() == "true" else "decp.info"
)
meta_content = {
"image_url": f"https://{domain_name}/assets/decp.info.png",
"title": "decp.info - exploration des marchés publics français",
"description": (
"Explorez et analysez les données des marchés publics français avec cet outil libre et gratuit. "
"Pour une commande publique accessible à toutes et tous."
),
}
data_schema = get_data_schema()
def make_org_jsonld(org_id, org_type, org_name=None, type_org_id="SIRET") -> dict:
org_types = {"acheteur": "GovernmentOrganization", "titulaire": "Organization"}
address = None
@@ -747,7 +727,8 @@ def make_org_jsonld(org_id, org_type, org_name=None, type_org_id="SIRET") -> dic
jsonld = {
"@type": org_types[org_type],
"name": org_name,
"url": f"https://decp.info/titulaires/{org_id}",
"url": f"https://decp.info/{org_type}s/{org_id}",
"sameAs": f"https://annuaire-entreprises.data.gouv.fr/etablissement/{org_id}",
"identifier": {
"@type": "PropertyValue",
"propertyID": type_org_id.lower(),
@@ -759,3 +740,42 @@ def make_org_jsonld(org_id, org_type, org_name=None, type_org_id="SIRET") -> dic
jsonld["address"] = address
return jsonld
df: pl.DataFrame = get_decp_data()
schema = df.collect_schema()
df_acheteurs = get_org_data(df, "acheteur")
df_titulaires = get_org_data(df, "titulaire")
df_acheteurs_departement: pl.DataFrame = (
df_acheteurs.select(["acheteur_id", "acheteur_nom", "acheteur_departement_code"])
.unique()
.sort("acheteur_nom")
)
df_titulaires_departement: pl.DataFrame = (
df_titulaires.select(
["titulaire_id", "titulaire_nom", "titulaire_departement_code"]
)
.unique()
.sort("titulaire_nom")
)
df_acheteurs_marches: pl.DataFrame = (
df.select("uid", "objet", "acheteur_id").unique().sort("acheteur_id")
)
df_titulaires_marches: pl.DataFrame = (
df.select("uid", "objet", "titulaire_id").unique().sort("titulaire_id")
)
departements = get_departements()
domain_name = (
"test.decp.info" if os.getenv("DEVELOPMENT").lower() == "true" else "decp.info"
)
meta_content = {
"image_url": f"https://{domain_name}/assets/decp.info.png",
"title": "decp.info - exploration des marchés publics français",
"description": (
"Explorez et analysez les données des marchés publics français avec cet outil libre et gratuit. "
"Pour une commande publique accessible à toutes et tous."
),
}
data_schema = get_data_schema()