aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--LICENSE21
-rw-r--r--README.md16
-rw-r--r--src/geojson2kml.py56
-rw-r--r--src/kml_utils.py80
-rw-r--r--src/n2000.py174
-rw-r--r--src/rn.py149
7 files changed, 321 insertions, 176 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..21d0b89
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+.venv/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..761ba03
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Damien Guillotin
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE. \ No newline at end of file
diff --git a/README.md b/README.md
index e3e099b..4af313c 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,13 @@
# Dog Friendly Data
-This repository contains scripts I used to scrape and process data related to dog-friendly locations.
+This repository contains scripts I used to scrape and process data related to potential dog-unfriendly locations.
## Data
-I collected the data to create a map of dog-unfriendly locations. The collected data are acutally meant to identify possible dog-prohibited areas. Only areas that are known to me to be dog-restricted are then moved to the concerned category. I always add a justification for each area, and I try to provide a link to the official source of information. If you find any errors or have additional information, please feel free contact me.
+I collected this data to create a map of dog-unfriendly locations. The collected data is actually meant to identify possible dog-prohibited areas; only areas that I've confirmed to be dog-restricted are then moved to the concerned category. I always add a justification for each area, and I try to provide a link to the official source of information. If you find any errors or have additional information, please feel free to contact me.
<iframe src="https://www.google.com/maps/d/u/0/embed?mid=1CxFX_yBhNfWjP3vAlOlEF-kZTWBncik&ehbc=2E312F&noprof=1" width="640" height="480"></iframe>
-Yellow : Leached only<br/>
+Yellow : Leashed only<br/>
Red : Prohibited / Not allowed<br/>
Blue : Protected area (check rules)<br/>
Unmarked : No specific regional restrictions found (or data is incomplete)<br/>
@@ -20,10 +20,14 @@ https://www.google.com/maps/d/edit?mid=1CxFX_yBhNfWjP3vAlOlEF-kZTWBncik&usp=shar
## Setup
```sh
-uv init
-uv install
+uv sync
```
## Scripts
-`test_export.py`: Tests the export of data to KML format for Google Maps. I used this to make sure I didn't make any mistakes while importing multiple data sources. Example: `uv run src/test_export.py exported_google_layer.kml 'source_files_*.kml'`
+- `kml_utils.py`: Shared helpers used by the scripts below to build KML documents (placemarks, polygons, writing the file). Not meant to be run directly.
+- `geojson2kml.py`: Converts a GeoJSON file (MultiPolygon features only) into a KML file. Example: `uv run src/geojson2kml.py input.geojson -o output.kml`
+- `n2000.py`: Scrapes [natura2000.fr](https://www.natura2000.fr/carte-natura2000) for every listed site and exports their boundaries to a KML file. Example: `uv run src/n2000.py -o natura_2000.kml`
+- `rn.py`: Scrapes [reserves-naturelles.org](https://reserves-naturelles.org/reserves-naturelles/) for every listed nature reserve and exports their boundaries to a KML file. Example: `uv run src/rn.py -o reserves_naturelles.kml`
+- `split_kml.py`: Splits a large KML file into several smaller files under a given size, so they stay under Google My Maps' per-layer size limit.
+- `test_export.py`: Tests the export of data to KML format for Google Maps. I used this to make sure I didn't make any mistakes while importing multiple data sources. Example: `uv run src/test_export.py exported_google_layer.kml 'source_files_*.kml'`
diff --git a/src/geojson2kml.py b/src/geojson2kml.py
index b923d98..8c116f0 100644
--- a/src/geojson2kml.py
+++ b/src/geojson2kml.py
@@ -1,60 +1,48 @@
+import argparse
import json
import xml.etree.ElementTree as ET
+from kml_utils import add_placemark, add_polygon, new_document, write_kml
-KML_NS = "http://www.opengis.net/kml/2.2"
-ET.register_namespace("", KML_NS)
+def geojson_to_kml(geojson: dict) -> ET.Element:
+ """Convert a GeoJSON FeatureCollection into a KML tree.
-def geojson_to_kml(geojson: dict) -> ET.ElementTree[ET.Element]:
- root = ET.Element(f"kml", xmlns=KML_NS)
- document = ET.SubElement(root, f"Document")
+ Only MultiPolygon features are supported; other geometry types are skipped.
- ET.SubElement(document, f"name").text = geojson.get("name", "Unnamed")
+ Args:
+ geojson (dict): Parsed GeoJSON FeatureCollection.
+ Returns:
+ ET.Element: The root <kml> element.
+ """
+ root, document = new_document(geojson.get("name", "Unnamed"))
for feature in geojson.get("features", []):
- placemark = ET.SubElement(document, f"Placemark")
-
- ET.SubElement(placemark, f"name").text = ""
- ET.SubElement(placemark, f"description").text = ""
-
- # TODO: Handle other geometry types if needed
- if feature["geometry"]["type"] != "MultiPolygon":
- print(f"Skipping feature with unsupported geometry type: {feature['geometry']['type']}")
+ geometry_type = feature["geometry"]["type"]
+ if geometry_type != "MultiPolygon":
+ print(f"Skipping feature with unsupported geometry type: {geometry_type}")
continue
- for polygon in feature["geometry"]["coordinates"][0]:
- polygon_el = ET.SubElement(placemark, f"Polygon")
-
- outer = ET.SubElement(polygon_el, f"outerBoundaryIs")
- ring = ET.SubElement(outer, f"LinearRing")
+ placemark = add_placemark(document)
+ for ring in feature["geometry"]["coordinates"][0]:
+ add_polygon(placemark, ring)
- coords = ET.SubElement(ring, f"coordinates")
- coords.text = "\n".join(
- f"{lon},{lat}"
- for lon, lat in polygon
- )
-
- ET.indent(root)
-
- return ET.ElementTree(root)
+ return root
def main():
- import argparse
-
parser = argparse.ArgumentParser(description="Convert GeoJSON to KML")
parser.add_argument("input_file", help="Path to the input GeoJSON file")
- parser.add_argument("-o", "--output_file", help="Path to the output KML file")
+ parser.add_argument("-o", "--output", help="Path to the output KML file")
args = parser.parse_args()
with open(args.input_file, "r") as geojson_file:
geojson = json.load(geojson_file)
- output_file = args.output_file or args.input_file.rsplit(".", 1)[0] + ".kml"
+ output_file = args.output or args.input_file.rsplit(".", 1)[0] + ".kml"
- kml = geojson_to_kml(geojson)
- kml.write(output_file, encoding="utf-8", xml_declaration=True)
+ root = geojson_to_kml(geojson)
+ write_kml(root, output_file)
if __name__ == "__main__":
diff --git a/src/kml_utils.py b/src/kml_utils.py
new file mode 100644
index 0000000..70319f8
--- /dev/null
+++ b/src/kml_utils.py
@@ -0,0 +1,80 @@
+"""Shared helpers for building simple KML documents from polygon data."""
+
+import xml.etree.ElementTree as ET
+
+KML_NS = "http://www.opengis.net/kml/2.2"
+ET.register_namespace("", KML_NS)
+
+
+def new_document(name: str) -> tuple[ET.Element, ET.Element]:
+ """Create a <kml><Document> pair and return (root, document).
+
+ Args:
+ name (str): Name of the document, shown as the map/layer title.
+ Returns:
+ tuple[ET.Element, ET.Element]: The root <kml> element and its child <Document> element.
+ """
+ root = ET.Element("kml", xmlns=KML_NS)
+ document = ET.SubElement(root, "Document")
+ ET.SubElement(document, "name").text = name
+ return root, document
+
+
+def add_placemark(document: ET.Element, name: str = "", description: str = "") -> ET.Element:
+ """Add a <Placemark> to `document` and return it.
+
+ Args:
+ document (ET.Element): The <Document> element to attach the placemark to.
+ name (str, optional): Placemark name. Defaults to "".
+ description (str, optional): Placemark description. Defaults to "".
+ Returns:
+ ET.Element: The newly created <Placemark> element.
+ """
+ placemark = ET.SubElement(document, "Placemark")
+ ET.SubElement(placemark, "name").text = name
+ ET.SubElement(placemark, "description").text = description
+ return placemark
+
+
+def add_polygon(parent: ET.Element, ring: list[tuple[float, float]]) -> ET.Element:
+ """Add a single-ring <Polygon> to `parent`.
+
+ Args:
+ parent (ET.Element): Element to attach the polygon to (a <Placemark> or <MultiGeometry>).
+ ring (list[tuple[float, float]]): Outer boundary points as (longitude, latitude) pairs.
+ Returns:
+ ET.Element: The newly created <Polygon> element.
+ """
+ polygon = ET.SubElement(parent, "Polygon")
+ outer = ET.SubElement(polygon, "outerBoundaryIs")
+ linear_ring = ET.SubElement(outer, "LinearRing")
+ coordinates = ET.SubElement(linear_ring, "coordinates")
+ coordinates.text = "\n".join(f"{lon},{lat}" for lon, lat in ring)
+ return polygon
+
+
+def add_multi_polygon(placemark: ET.Element, rings: list[list[tuple[float, float]]]) -> ET.Element:
+ """Add a <MultiGeometry> made of one <Polygon> per ring to `placemark`.
+
+ Args:
+ placemark (ET.Element): The <Placemark> element to attach the geometry to.
+ rings (list[list[tuple[float, float]]]): One outer boundary ring per polygon.
+ Returns:
+ ET.Element: The newly created <MultiGeometry> element.
+ """
+ multi_geometry = ET.SubElement(placemark, "MultiGeometry")
+ for ring in rings:
+ add_polygon(multi_geometry, ring)
+ return multi_geometry
+
+
+def write_kml(root: ET.Element, output_file: str) -> None:
+ """Indent and write a KML tree to `output_file`.
+
+ Args:
+ root (ET.Element): The root <kml> element to write.
+ output_file (str): Path to the output KML file.
+ """
+ tree = ET.ElementTree(root)
+ ET.indent(tree)
+ tree.write(output_file, encoding="utf-8", xml_declaration=True)
diff --git a/src/n2000.py b/src/n2000.py
index 357f744..a0741a0 100644
--- a/src/n2000.py
+++ b/src/n2000.py
@@ -1,96 +1,126 @@
+import argparse
+import json
import re
+import xml.etree.ElementTree as ET
+
import requests
+from kml_utils import add_multi_polygon, add_placemark, new_document, write_kml
+
+MAP_URL = "https://www.natura2000.fr/carte-natura2000"
+POPUP_URL_TEMPLATE = (
+ "https://www.natura2000.fr/leaflet-ajax-popup/node/{entity_id}"
+ "/pop_up_n2000_field/und?_wrapper_format=drupal_ajax"
+)
+SITE_URL_TEMPLATE = "https://www.natura2000.fr/site-natura/{slug}"
-reponse = requests.get("https://www.natura2000.fr/carte-natura2000")
-regex = r"\"entity_id\":\"(\d+)\""
-matches = re.findall(regex, reponse.text)
+def get_entity_ids(map_url: str = MAP_URL) -> list[str]:
+ """Fetch the Natura 2000 map page and extract every site's numeric entity id.
-kml_file = open("natura_2000.kml", "w", encoding="utf-8")
-kml_file.write("""<?xml version="1.0" encoding="UTF-8"?>
-<kml xmlns="http://www.opengis.net/kml/2.2">
- <Document>
- <name>Natura 2000</name>
-""")
+ Args:
+ map_url (str, optional): URL of the Natura 2000 map page. Defaults to MAP_URL.
+ Returns:
+ list[str]: Entity ids found on the page.
+ """
+ response = requests.get(map_url)
+ return re.findall(r"\"entity_id\":\"(\d+)\"", response.text)
-for i, match in enumerate(matches):
- url = f"https://www.natura2000.fr/leaflet-ajax-popup/node/{match}/pop_up_n2000_field/und?_wrapper_format=drupal_ajax"
- response = requests.get(url)
+def get_site_slug(entity_id: str) -> str | None:
+ """Resolve an entity id to its site page URL slug via the popup endpoint.
- regex = r"href=\\u0022\\/site-natura\\/(.*?)\\u0022"
- match = re.search(regex, response.text)
+ Args:
+ entity_id (str): Numeric entity id, as found by get_entity_ids.
+ Returns:
+ str | None: The site URL slug, or None if it could not be found.
+ """
+ response = requests.get(POPUP_URL_TEMPLATE.format(entity_id=entity_id))
+ match = re.search(r"href=\\u0022\\/site-natura\\/(.*?)\\u0022", response.text)
+ return match.group(1) if match else None
- if not match:
- print(f"No match found for entity_id {match}.")
- continue
- site_url = f"https://www.natura2000.fr/site-natura/{match.group(1)}"
+def get_site_details(site_url: str) -> tuple[str, list[list[tuple[float, float]]]] | None:
+ """Fetch a site page and extract its name and outer polygon rings.
+
+ Args:
+ site_url (str): URL of the site page.
+ Returns:
+ tuple[str, list[list[tuple[float, float]]]] | None: (name, rings), where each ring is
+ a list of (longitude, latitude) points, or None if the name, geometry type, or
+ coordinates could not be found on the page.
+ """
response = requests.get(site_url)
- regex = r"<title>(.*?) \| Natura 2000</title>"
- match = re.search(regex, response.text)
- if not match:
+ name_match = re.search(r"<title>(.*?) \| Natura 2000</title>", response.text)
+ if not name_match:
print(f"No name found for {site_url}.")
- continue
- name = match.group(1)
+ return None
+ name = name_match.group(1)
- regex = r"\"type\":\"(multipolygon|polygon)\""
- match = re.search(regex, response.text)
- if not match:
+ type_match = re.search(r"\"type\":\"(multipolygon|polygon)\"", response.text)
+ if not type_match:
print(f"No geometry type found for {site_url}.")
- continue
- geometry_type = match.group(1)
-
+ return None
+ geometry_type = type_match.group(1)
+
if geometry_type == "polygon":
- regex = r"\"points\":(\[\[.*?\]\])"
- elif geometry_type == "multipolygon":
- regex = r"\"points\":(\[\[\[.*?\]\]\])"
+ points_regex = r"\"points\":(\[\[.*?\]\])"
else:
- print(f"Unsupported geometry type {geometry_type} for {site_url}.")
- continue
+ points_regex = r"\"points\":(\[\[\[.*?\]\]\])"
- match = re.search(regex, response.text)
- if not match:
+ points_match = re.search(points_regex, response.text)
+ if not points_match:
print(f"No coordinates found for {site_url}.")
- continue
- polygons = eval(match.group(1))
-
+ return None
+
+ polygons = json.loads(points_match.group(1))
if geometry_type == "polygon":
- # Wrap single polygon in a list to treat it as multipolygon
- polygons = [polygons]
+ polygons = [polygons] # normalize to a list of polygons, like multipolygon
+
+ rings = [[(point["lon"], point["lat"]) for point in polygon[0]] for polygon in polygons]
+ return name, rings
+
+
+def scrape_natura2000(map_url: str = MAP_URL) -> ET.Element:
+ """Scrape natura2000.fr and build a KML tree with one Placemark per site.
+
+ Args:
+ map_url (str, optional): URL of the Natura 2000 map page. Defaults to MAP_URL.
+ Returns:
+ ET.Element: The root <kml> element.
+ """
+ root, document = new_document("Natura 2000")
+
+ entity_ids = get_entity_ids(map_url)
+ for i, entity_id in enumerate(entity_ids):
+ slug = get_site_slug(entity_id)
+ if slug is None:
+ print(f"No site URL found for entity_id {entity_id}.")
+ continue
+
+ site_url = SITE_URL_TEMPLATE.format(slug=slug)
+ details = get_site_details(site_url)
+ if details is None:
+ continue
+ name, rings = details
+
+ placemark = add_placemark(document, name=name, description=f"{name}<br/>{site_url}")
+ add_multi_polygon(placemark, rings)
+
+ print(f"Processed: {name} ({i + 1:5d}/{len(entity_ids)})")
+
+ return root
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Scrape natura2000.fr and export sites to KML")
+ parser.add_argument("-o", "--output", default="natura_2000.kml", help="Path to the output KML file")
+ args = parser.parse_args()
- kml_file.write(f"""
- <Placemark>
- <name>{name}</name>
- <description>
- {name}<br/>{site_url}
- </description>
- <MultiGeometry>
- """)
+ root = scrape_natura2000()
+ write_kml(root, args.output)
- for polygon in polygons:
- kml_file.write("""
- <Polygon>
- <outerBoundaryIs>
- <LinearRing>
- <coordinates>
- """)
- for point in polygon[0]:
- kml_file.write(f"{point['lon']},{point['lat']},0\n")
- kml_file.write("""
- </coordinates>
- </LinearRing>
- </outerBoundaryIs>
- </Polygon>
- """)
- kml_file.write(f"""
- </MultiGeometry>
- </Placemark>
- """)
-
- print(f"Processed: {name} ({i+1: 5d}/{len(matches)})")
-kml_file.write(""" </Document>
-</kml>""")
+if __name__ == "__main__":
+ main()
diff --git a/src/rn.py b/src/rn.py
index 0c6589c..c78f1c2 100644
--- a/src/rn.py
+++ b/src/rn.py
@@ -1,81 +1,102 @@
-import requests
+import argparse
+import json
import re
+import xml.etree.ElementTree as ET
+
+import requests
+
+from kml_utils import add_placemark, add_polygon, new_document, write_kml
+LIST_URL = "https://reserves-naturelles.org/reserves-naturelles/"
-def get_coordinates_from_url(url):
+
+def get_coordinates_from_url(url: str) -> list[tuple[float, float]] | None:
+ """Fetch a reserve page and extract its outer polygon ring.
+
+ Args:
+ url (str): URL of the reserve page.
+ Returns:
+ list[tuple[float, float]] | None: (longitude, latitude) points, or None if no
+ coordinates could be found on the page.
+ """
response = requests.get(url)
- regex = r"\[\[-?\d+\.\d+,-?\d+\.\d+\](?:,\[-?\d+\.\d+,-?\d+\.\d+\])+\]"
- matche = re.search(regex, response.text)
- if not matche:
+ match = re.search(
+ r"\[\[-?\d+\.\d+,-?\d+\.\d+\](?:,\[-?\d+\.\d+,-?\d+\.\d+\])+\]", response.text
+ )
+ if not match:
return None
- coordinates = eval(matche.group(0))
- return coordinates
+ return [tuple(point) for point in json.loads(match.group(0))]
+
+
+def get_reserves(list_url: str = LIST_URL) -> list[dict]:
+ """Fetch the reserves list page and extract each reserve's name and URL.
+
+ Args:
+ list_url (str, optional): URL of the reserves list page. Defaults to LIST_URL.
+ Returns:
+ list[dict]: One {"full_name", "name", "url"} entry per reserve found on the page.
+ """
+ response = requests.get(list_url)
+ items = re.findall(r"<li class=\"reserve\"[\s\S]*?</li>", response.text)
+
+ reserves = []
+ for item in items:
+ full_name_match = re.search(r"title=\"([^\"]+)\"", item)
+ name_match = re.search(r"class=\"rsv_nom\">([^<]+)<", item)
+ url_match = re.search(r"href=\"([^\"]+)\"", item)
+
+ if not full_name_match or not name_match or not url_match:
+ print(f"Skipping incomplete list entry: {item}")
+ continue
+
+ reserves.append(
+ {
+ "full_name": full_name_match.group(1),
+ "name": name_match.group(1),
+ "url": url_match.group(1),
+ }
+ )
+
+ return reserves
-response = requests.get("https://reserves-naturelles.org/reserves-naturelles/")
-regex = r"<li class=\"reserve\"[\s\S]*?</li>"
-matches = re.findall(regex, response.text)
+def scrape_reserves(list_url: str = LIST_URL) -> ET.Element:
+ """Scrape reserves-naturelles.org and build a KML tree with one Placemark per reserve.
-kml_file = open("reserves_naturelles.kml", "w", encoding="utf-8")
-kml_file.write("""<?xml version="1.0" encoding="UTF-8"?>
-<kml xmlns="http://www.opengis.net/kml/2.2">
- <Document>
- <name>RN</name>
-""")
+ Args:
+ list_url (str, optional): URL of the reserves list page. Defaults to LIST_URL.
+ Returns:
+ ET.Element: The root <kml> element.
+ """
+ root, document = new_document("RN")
+ reserves = get_reserves(list_url)
+ for i, reserve in enumerate(reserves):
+ coordinates = get_coordinates_from_url(reserve["url"])
+ if not coordinates:
+ print(f"No coordinates found for URL: {reserve['url']}")
+ continue
-for i, match in enumerate(matches):
+ placemark = add_placemark(
+ document,
+ name=reserve["name"],
+ description=f"{reserve['full_name']}<br/>{reserve['url']}",
+ )
+ add_polygon(placemark, coordinates)
- regex = r"title=\"([^\"]+)\""
- full_name = re.search(regex, match)
- if not full_name:
- print(f"Full name not found in the match: {match}")
- continue
- full_name = full_name.group(1)
+ print(f"Processed: {reserve['name']} ({i + 1:3d}/{len(reserves)})")
- regex = r"class=\"rsv_nom\">([^<]+)<"
- name = re.search(regex, match)
- if not name:
- print(f"Name not found in the match: {match}")
- continue
- name = name.group(1)
+ return root
- regex = r"href=\"([^\"]+)\""
- url = re.search(regex, match)
- if not url:
- print(f"URL not found in the match: {match}")
- continue
- url = url.group(1)
- coordinates = get_coordinates_from_url(url)
- if not coordinates:
- print(f"Coordinates not found for URL: {url}")
- continue
+def main():
+ parser = argparse.ArgumentParser(description="Scrape reserves-naturelles.org and export reserves to KML")
+ parser.add_argument("-o", "--output", default="reserves_naturelles.kml", help="Path to the output KML file")
+ args = parser.parse_args()
- kml_file.write(f"""
- <Placemark>
- <name>{name}</name>
- <description>
- {full_name}<br/>{url}
- </description>
- <Polygon>
- <outerBoundaryIs>
- <LinearRing>
- <coordinates>
-""")
- for coordinate in coordinates:
- kml_file.write(f" {coordinate[0]},{coordinate[1]},0\n")
- kml_file.write("""
- </coordinates>
- </LinearRing>
- </outerBoundaryIs>
- </Polygon>
- </Placemark>
-""")
-
- print(f"Processed: {name} ({i+1: 3d}/{len(matches)})")
+ root = scrape_reserves()
+ write_kml(root, args.output)
-kml_file.write("""
- </Document>
-</kml>""")
+if __name__ == "__main__":
+ main()