diff --git a/CHANGELOG.md b/CHANGELOG.md index ef3aa24..efc84aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/app.py b/src/app.py index 0bf8cd2..a0d62b5 100644 --- a/src/app.py +++ b/src/app.py @@ -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, diff --git a/src/assets/style.css b/src/assets/style.css index a8d203d..ff06faa 100644 --- a/src/assets/style.css +++ b/src/assets/style.css @@ -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; diff --git a/src/figures.py b/src/figures.py index 5d6a723..aff70c1 100644 --- a/src/figures.py +++ b/src/figures.py @@ -169,14 +169,22 @@ def get_sources_tables(source_path) -> html.Div: + pl.lit("") ).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", diff --git a/src/pages/a-propos.py b/src/pages/a-propos.py index 8ff9dab..04c6006 100644 --- a/src/pages/a-propos.py +++ b/src/pages/a-propos.py @@ -122,6 +122,14 @@ Rien d’exceptionnel, je respecte simplement la loi, qui dit que certains outil J’utilise 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 m’est donc impossible d’associer 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 diff --git a/src/pages/acheteur.py b/src/pages/acheteur.py index 65419ec..f3ae843 100644 --- a/src/pages/acheteur.py +++ b/src/pages/acheteur.py @@ -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( diff --git a/src/pages/departement.py b/src/pages/departement.py new file mode 100644 index 0000000..303ef80 --- /dev/null +++ b/src/pages/departement.py @@ -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/", + 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 diff --git a/src/pages/departements.py b/src/pages/departements.py new file mode 100644 index 0000000..48c74d5 --- /dev/null +++ b/src/pages/departements.py @@ -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() + ] + ), + ] +) diff --git a/src/pages/liste_marches_org.py b/src/pages/liste_marches_org.py new file mode 100644 index 0000000..6629333 --- /dev/null +++ b/src/pages/liste_marches_org.py @@ -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///", + 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 diff --git a/src/pages/marche.py b/src/pages/marche.py index 632cac7..f0f4f2f 100644 --- a/src/pages/marche.py +++ b/src/pages/marche.py @@ -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"), diff --git a/src/pages/tableau.py b/src/pages/tableau.py index 35bdfc9..a8cf2bd 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -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=[ diff --git a/src/pages/titulaire.py b/src/pages/titulaire.py index a17e505..60fdc2b 100644 --- a/src/pages/titulaire.py +++ b/src/pages/titulaire.py @@ -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( diff --git a/src/utils.py b/src/utils.py index 1c8474e..016752e 100644 --- a/src/utils.py +++ b/src/utils.py @@ -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()