3  Création d’une base réglementaire à partir de CNLEGIS

3.1 Objet

Le processus de création, modification ou disparition des aires protégées passe par des lois, décrets et arrêtés officiels. Ces éléments réglementaires sont dispersés dans divers textes juridiques, que nous allons ici collecter et consolider à partir de la base du Centre National d’Information et de Documentation Legislative et Juridique (CNLEGIS). Cette instance centralise les textes réglementaires à Madagascar et propose un moteur de recherche permettant de trouver les documents pertinents en fonction de recherches full-text. Cette section détaille la méthodologie employée pour collecter, traiter et structurer ces informations.

3.2 Extraction des textes pertinents

La base CNLEGIS ne propose pas d’API, donc l’extraction a dû être réalisée manuellement, à partir de requête contenant les expressions reconnues pour désigner les aires protégées. Loi N° 90-033 du 21 décembre 1990 portant Charte de l’Environnement Malgache n’établissait pas de typologies pour les aires protégées et se référait simplement aux “parcs et réserves”. Dans la Loi n° 2001-005 portant code de gestion des aires protégées établissait trois catégories : Réserve naturelle intégrale, Parc national, et Réserve spéciale. La Loi n°2008-025 portant refonte du Code de Gestion des Aires Protégées étend cette liste à 7 catégories : La Réserve Naturelle Intégrale (RNI), le Parc National (PN), la Réserve Spéciale (RS), le Parc Naturel (PNAT), le Monument Naturel (MONAT), le Paysage Harmonieux Protégé (PHP), et la Réserve de Ressources Naturelles (RRN). La Loi n°2015-005 portant refonte du Code de Gestion des Aires Protégées reprend cette même liste.

Sur cette base, on a entré les requêtes suivantes, qui ont donné le nombre de résultats indiqués ensuite :

  • aire protégée : 131
  • monument naturel : 0
  • parc national : 29
  • parc naturel : 1
  • paysage harmonieux : 1
  • réserve naturelle : 12
  • réserves naturelles :
  • réserve spéciale : 21
  • réserve de ressource : 0
  • protection temporaire globale : 3
  • réserves de faunes : 1
  • réserve de faune : 3

Les résultats de chaque requête sont regroupées par pages contenant entre 1 et 2 réponses. Nous avons extrait les 25 pages web contenant les résultats ont été enregistrées localement. Le code ci-dessous extrait les informations pertinentes qu’elles contiennent, les nettoient et les restitue sous forme tabulaire.

Voir le code
library(rvest)
library(tidyverse)
library(sf)
library(wdpar)

# Specify the source repository
data_dir <- "sources/"
# Directory containing the HTML files
input_dir <- paste0(data_dir, "Décrets/CNLEGIS_complet")


# Columns to select
keep_columns <- c(
  "DATE(S)",
  "DATE TEXTES",
  "TEXTE(S)",
  "TYPE",
  "NUM TEXTE",
  "NUM",
  "OBJET",
  "OBJET MG",
  "NUM JO",
  "DATE JO",
  "DATE JO FR",
  "PAGE JO",
  "ETAT",
  "NOTE(S)",
  "NOTES MG",
  "DOC PDF FR",
  "DOC PDF MG",
  "MINISTERE(S)",
  "id",
  "HTML FR",
  "HTML MG"
)
keep_types <- c(
  "Constitution",
  "Loi constitutionnelle",
  "Loi organique",
  "Loi",
  "Ordonnance",
  "Décret",
  "Arrêté",
  "Circulaire",
  "Décision",
  "Instruction",
  "Note",
  "Délibération",
  "Arrêt",
  "Avis",
  "Palmarès",
  "Procès verbal",
  "Exposé des motifs de la loi",
  "Jugement",
  "Déclaration"
)

# Function to clean column names
clean_column_names <- function(names) {
  names %>%
    str_replace_all("\\(|\\)", "") %>% # Remove parentheses
    str_replace_all(" ", "_") %>% # Replace spaces with underscores
    str_to_lower() # Convert to lowercase
}


# Function to extract the desired table from a single HTML file
extract_table <- function(file_path) {
  # Read the HTML file
  html <- read_html(file_path)

  # Extract the table
  table <- html %>%
    html_node("table#Lst_docs") %>%
    html_table(fill = TRUE)

  # Select the relevant columns
  selected_columns <- table %>%
    select(all_of(keep_columns)) %>%
    filter(TYPE %in% keep_types)

  colnames(selected_columns) <- clean_column_names(colnames(selected_columns))

  # Ensure all columns are converted to text
  selected_columns <- selected_columns %>%
    mutate(across(everything(), as.character))

  return(selected_columns)
}

# Get a list of all HTML files in the directory
# Directory containing the HTML files
input_dir_complet <- paste0(data_dir, "Décrets/CNLEGIS_complet")
input_dir_add <- paste0(data_dir, "Décrets/CNLEGIS_add")

# Get a list of all HTML files from both directories
html_files <- c(
  list.files(input_dir_complet, pattern = "\\.html?$", full.names = TRUE),
  list.files(input_dir_add, pattern = "\\.htm$", full.names = TRUE)
)

# Apply the extraction function to all files
all_results <- map_dfr(html_files, extract_table) |>
  mutate(
    # Clean invalid date values before parsing
    date_jo = if_else(str_detect(date_jo, "undefined"), NA_character_, date_jo),
    # Parse dates
    date_txt = dmy(date_textes),
    date_jo = dmy(date_jo),
    # Use date_txt as fallback if date_jo is missing
    date_jo = coalesce(date_jo, date_txt)
  ) |>
  relocate(date_txt, date_jo, .before = everything())

all_results |>
  select(
    -html_fr,
    -html_mg,
    -objet_mg,
    -textes,
    -num,
    -doc_pdf_fr,
    -doc_pdf_mg
  ) |>
  DT::datatable(height = 10)

3.3 Classification par type de réglement

Les décisions identifiées ont des effets variés : certaines créent de nouvelles aires protégées, d’autres modifient leurs limites, tandis que d’autres prorogent leur statut. Nous appliquons des règles de typologie permettant d’assigner une catégorie à chaque texte.

Voir le code
# Add typology columns
class_results <- all_results %>%
  mutate(
    creation_definitive = str_detect(
      textes,
      regex(
        "(?<!en )(création|crétion de l'aire protégée)|instituant|classant",
        ignore_case = TRUE
      )
    ),
    modifiant = str_detect(
      textes,
      regex("(modifi)|(changem)|Précis", ignore_case = TRUE)
    ),
    prorogation = str_detect(textes, regex("prorog", ignore_case = TRUE)),
    delegation_gestion = str_detect(
      textes,
      regex("délégation de gestion", ignore_case = TRUE)
    ),
    mise_protection_temporaire = str_detect(
      textes,
      regex("protection temporaire", ignore_case = TRUE)
    ),
    nomination = str_detect(textes, regex("nomination", ignore_case = TRUE))
  ) %>%
  mutate(
    total_true = rowSums(
      select(., creation_definitive:nomination),
      na.rm = TRUE
    ),
    concatenated_true = pmap_chr(
      select(., creation_definitive:nomination),
      ~ paste(names(which(c(...))), collapse = ", ")
    ),
    .before = everything()
  ) %>%
  filter(!(total_true == 0)) %>%
  filter(!(total_true == 1 & nomination)) %>% # On enlève le copil Sydney
  filter(!(str_detect(textes, "fonctionnement du Comité de Pilotage"))) %>%
  filter(!(str_detect(textes, "fonctionnement de la Commission"))) %>%
  filter(!(str_detect(textes, "organisation du Comité")))

Deux exceptions méritent d’être relevées :

Ces deux textes concernent une liste très étendue de sites potentiels, qui ne sont pas nommément listés dans le texte. On a juste une carte (peu précise) et une somme de zones concernées (en nombre et en surface). Nous devons donc croiser ces informations avec la base SAPM pour retrouver les sites affectés.

3.4 Appariement des noms d’aires protégées entre CNLEGIS et SAPM

Les noms extraits des textes officiels peuvent varier par rapport aux noms standardisés dans les bases spatiales (SAPM, WDPA), en raison de différences d’orthographe, de formulation, ou de précision géographique. Un nettoyage et une extraction robuste sont donc nécessaires avant tout appariement.

Voir le code
class_results <- class_results %>%
  mutate(
    pa = str_extract(
      textes,
      regex("[\"«](.*?)[\"»,]|nommée\\s*(.*?)[»,]", ignore_case = TRUE)
    ),
    pa = ifelse(
      is.na(pa),
      str_extract(
        textes,
        regex("nommée\\s*(.*?)[\"]", ignore_case = TRUE)
      ),
      pa
    ),
    pa = ifelse(
      str_detect(textes, "respectivement"),
      str_extract(
        textes,
        regex("respectivement\\s+(.*?)(?=\\.|N°|ETAT)", ignore_case = TRUE)
      ),
      pa
    ),
    # Add pattern for "station forestière d'" or "réserve de faune"
    pa = ifelse(
      is.na(pa) & str_detect(textes, "station forestière d'"),
      str_extract(
        textes,
        regex("(?<=station forestière d')[^,]+", ignore_case = TRUE)
      ),
      pa
    ),
    pa = str_replace(pa, "\"Complexe des Aires", "Complexe des Aires"),
    pa = str_remove(pa, "^nommée\\s*"), # Supprime "nommée"
    pa = str_remove(pa, "^respectivement\\s*"), # Supprime "nommée"
    pa = str_remove(pa, "(?<=\")[^\"«]*$"), # Supprime tout après le 2e guillemet
    pa = str_remove_all(pa, "[\"«»]"), # Supprime les quotes
    pa = str_trim(pa), # Supprime les espaces en trop
    pa = str_remove(pa, ",$"),
    pa = str_remove(pa, "^'"),
    .before = textes
  )

# Cleans PA name columns only (e.g. NOM, SHORT_NAME, etc.)
# You must specify which columns to clean
clean_pa_names_cols <- function(df, name_cols) {
  df %>%
    mutate(across(
      all_of(name_cols),
      ~ .x %>%
        str_replace_all("[\r\n\t]", " ") %>% # replace line breaks, carriage returns, tabs with space
        str_squish() %>% # collapse multiple spaces into one
        str_trim() # remove leading/trailing whitespace
    ))
}
# Clean extracted PA names (remove \r, \n, tabs, trim spaces)
class_results <- clean_pa_names_cols(class_results, name_cols = "pa")

pn_rs <- c(
  "Andohahela",
  "Nosy Mangabe",
  "Montagne d'Ambre",
  "Ankarafantsika",
  "Analamazaotra",
  "Kirindy Mite",
  "Tsimanampesotse",
  "Mikea",
  "Nosy Hara",
  "Nosy Tanikely",
  "Lokobe",
  "Ankarafantsika",
  "Tsimanampetsotsa",
  "Namokora",
  "Mantadia",
  "Marojejy",
  "Kirindy Mite",
  "Befotaka Midongy",
  "Zombitse-Vohibasia",
  "Baie de Baly",
  "Tsingy-de-Bemaraha",
  "Masoala",
  "Mantadia",
  "Isalo",
  "montagne d'Ambre",
  "Lokobe",
  "Ankarafantsika",
  "Tsimanampetsotsa",
  "Namokora",
  "Marojejy",
  "Tsingy-de-Bemaraha",
  "Andringitra",
  "Ankarafantsika",
  "Ankarafantsika",
  "Nosy Mangabe",
  "Montagne d'Ambre",
  "Manongarivo",
  "Ambatovaky",
  "Beza-Mahafaly",
  "Cap Sainte Marie",
  "Anjanaharibe-Sud",
  "forêt d'Ambohitantely",
  "Manombo",
  "île de Mangabe",
  "Ambatovavy",
  "pic d'Ivohibe",
  "Mangerivola",
  "Manombo",
  "cap Sainte-Marie",
  "forêt d'Ambre",
  "forêt Tampoketsa d'Analamaitso",
  "Andranomena",
  "Anjanaharibe-Sud",
  "Ambohijanahary",
  "Pointe à Larrée"
) |>
  unique() |>
  sort()

class_results <- class_results %>%
  mutate(
    textes = str_replace_all(textes, "[\r\n]", " "),
    textes = str_replace_all(textes, "/", " "),
    textes = str_squish(textes),
    pa = ifelse(
      is.na(pa),
      map_chr(
        textes,
        ~ first(pn_rs[str_detect(.x, pn_rs)], default = NA_character_)
      ),
      pa
    ), # for some unkown reason, "Pointe à Larrée is not recognized
    pa = ifelse(num_texte == "2015-773", "Pointe à Larrée", pa), # Explicit assignment
    pa = ifelse(num_texte == "98-376", "Andrigitra", pa) # Explicit assignment
  )

class_results <- class_results %>%
  mutate(
    # For 2008, we find the list of PA created then
    pa = ifelse(
      num_texte == "18633/2008" |
        num_texte == "52005/2010" |
        num_texte == "9874/2013",
      read_rds("data/no_id/sapm_2010.rds") |>
        filter(YEAR_IMPLE == "X") |>
        pluck("NOM") |>
        paste(collapse = ", "),
      pa
    )
  )

Une fois les noms des aires protégées concernées extraites de chaque texte, on les compare aux noms de la base SAPM. On le fait tout d’abord automatiquement avec un algorithme d’appariement approximatif (“fuzzy matching”), qui tient compte des variations d’orthographe ou des dénominations multiple, afin d’identifier les correspondances les plus probables. Nous utilisons la distance de Levenshtein proportionnelle à la longueur des chaînes afin de minimiser les erreurs d’appariement sur des noms courts.

Voir le code
library(stringdist)
library(fuzzyjoin)


wdpa_mdg_2025 <- wdpa_read("sources/WDPA_WDOECM_mar2025_Public_MDG.zip")

# Harmonization for comparison (without modifying original data)
wdpa_clean <- wdpa_mdg_2025 %>%
  mutate(cleaned_NAME = str_trim(str_to_lower(NAME)))

class_clean <- class_results %>%
  mutate(cleaned_pa = str_trim(str_to_lower(pa)))

# Split cells containing multiple names (both ", " and " et " as separators)
split_class_clean <- class_clean %>%
  mutate(cleaned_pa_split = str_split(cleaned_pa, ",\\s*|\\s+et\\s+")) %>% # Split on commas or " et "
  unnest(cleaned_pa_split) %>%
  rename(single_cleaned_pa = cleaned_pa_split) # Rename for clarity

# Function to compute proportional string distance
match_pa_to_wdpa <- function(pa_name, wdpa_names) {
  # Calculate proportional distances
  distances <- stringdist(
    a = pa_name,
    b = wdpa_names,
    method = "lv" # Levenshtein distance
  ) /
    pmax(nchar(pa_name), nchar(wdpa_names))

  # Find the best match
  best_match_idx <- which.min(distances)
  list(
    closest_match = wdpa_names[best_match_idx],
    match_distance = distances[best_match_idx]
  )
}

# Find closest matches for each split name
conversion_table <- split_class_clean %>%
  filter(!is.na(single_cleaned_pa), single_cleaned_pa != "") %>% # Remove NA/empty values
  rowwise() %>%
  mutate(
    distances = list(
      stringdist(single_cleaned_pa, wdpa_clean$cleaned_NAME, method = "lv") /
        pmax(nchar(single_cleaned_pa), nchar(wdpa_clean$cleaned_NAME))
    ),
    best_idx = ifelse(length(distances) > 0, which.min(distances), NA_integer_),
    closest_match = ifelse(
      !is.na(best_idx),
      wdpa_clean$NAME[best_idx],
      NA_character_
    ),
    match_distance = ifelse(!is.na(best_idx), min(distances), NA_real_)
  ) %>%
  ungroup() %>%
  select(original_pa = single_cleaned_pa, closest_match, match_distance) %>%
  distinct(original_pa, .keep_all = TRUE)

writexl::write_xlsx(conversion_table, "conversion_table.xlsx")

On a à ce stade effectué une modification manuelle, correspondant à la réserve naturelle intégrale n°5 devenue parc national n°14 en 1998 est dénomée “Antsiranana” dans la base, mais il s’agit d’Andrigitra, cf. site 47 dans Goodman et al. (2018).

Cette base est envoyée vers excel et on effectue une vérification manuelle pour avoir une liste complète des rapprochements. A l’issue de ce travail, on dispose d’une liste des noms des AP tels qu’on les trouve dans les textes officiels, avec une table de correspondance indiquant le nom équivalent avec lequel elles sont enregistrées dans la base SAPM 2017.

3.5 Intégration des correspondances validées et finalisation de la base

Après validation des correspondances, nous consolidons la base en associant chaque décision réglementaire à l’aire protégée correspondante. Cela nous permet d’établir une base réglementaire normalisée, directement exploitable pour des analyses historiques ou spatiales.

Voir le code
library(readxl)

# Load the verified conversion table
conversion_table_verif <- read_xlsx("data/conversion_table_verif.xlsx") %>%
  select(original_pa, WDPA_NAME, WDPAID) %>%
  unique() %>%
  clean_pa_names_cols(name_cols = c("original_pa", "WDPA_NAME"))


# Merge with split_class_clean to integrate the verified closest matches
decision_pa <- split_class_clean %>%
  left_join(
    conversion_table_verif,
    by = c("single_cleaned_pa" = "original_pa")
  ) %>%
  distinct(pa, num_texte, .keep_all = TRUE) %>%
  select(
    date_texte = date_txt,
    ap_nom_texte = pa,
    WDPA_NAME,
    WDPAID,
    texte = textes,
    type_texte = type,
    num_texte,
    num_texte_variante = num,
    id_texte = id,
    objet_texte = objet,
    type_decision = concatenated_true,
    date_jo,
    page_jo,
    etat_texte = etat,
    notes_texte = notes,
    ministeres,
    html_fr,
    html_mg,
    doc_pdf_fr,
    doc_pdf_mg
  )

write_rds(decision_pa, "data/id/legal_texts.rds")
zip::zip(
  "data/id/legal_texts.zip",
  files = "data/id/legal_texts.rds",
  mode = "cherry-pick"
)

La base ainsi consolidée peut servir de référence pour des travaux d’analyse juridique, historique ou spatiale sur la dynamique des aires protégées à Madagascar. Elle constitue aussi une base d’appui pour la production d’indicateurs ou la vérification des statuts réglementaires.

Nous disposons maintenant d’une base de données consolidée des décisions réglementaires, où chaque texte est relié aux aires protégées qu’il concerne. Cette base est prête à être croisée avec les données spatiales pour analyser l’évolution historique des aires protégées à Madagascar.