Files
FabriceandClaude Opus 5.5 97b936fd3e Refonte : templates, balades, photos web, aperçus, carte globale
- Mise en page sortie du Python : Scripts/templates/ (Jinja2 + CSS/JS
  communs copiés dans assets/ de chaque voyage)
- Voyages rangés dans un sous-dossier (Balades/…) : rubriques sur
  l'accueil, 2 plus récents par rubrique, pages voyages.html et
  balades.html
- prepare_web.py : photos allégées (photos_web/) et images d'aperçu
  des vidéos (apercus/)
- Voyage d'une seule journée : la page du voyage est la carte du jour
- Carte globale de l'accueil (OpenStreetMap) : voyages du site + cartes
  Google My Maps récupérées (mymaps.txt, _mymaps/), moto par voyage
  (moto.txt)
- KML par voyage pour My Maps
- compress.sh : réencode une sortie illisible (encodage interrompu) ;
  repli automatique sur l'autre qualité dans la page
- Corrections : CSV vide (voyage sans photo), racine de genere_site.sh,
  fichiers ._ de macOS
- README réécrit, commentaires allégés

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim
2026-10-09 11:08:01 +00:00

395 lines
17 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Carte globale du site (carte_globale.html), affichée en grand sur l'accueil.
Fond OpenStreetMap (ou satellite Esri), sans Google. Elle réunit, en calques
que l'on peut afficher ou masquer :
- le contenu récupéré de cartes Google My Maps (anciennes balades et
voyages), avec leurs couleurs d'origine, un calque par calque My Maps ;
- les voyages du site (rubrique Voyages, pas les balades du site), chaque
trace menant à la page du voyage.
Récupération des cartes My Maps :
- mymaps.txt à la racine du site : un lien de carte My Maps par ligne
(# pour un commentaire), carte partagée « Tous les utilisateurs disposant
du lien ». Le KML est téléchargé UNE fois dans _mymaps/<id>.kml ; ensuite
la carte ne dépend plus de Google. MYMAPS_MAJ=1 pour le récupérer à nouveau
après avoir modifié la carte Google.
- ou tout export .kml / .kmz déposé à la main dans _mymaps/.
Appelé par genere_index_general.py ; utilisable seul :
./genere_carte_globale.py [racine_du_site]
"""
import json
import math
import os
import re
import shutil
import subprocess
import sys
import urllib.request
import zipfile
import xml.etree.ElementTree as ET
from urllib.parse import parse_qs, urlparse
import folium
import gpxpy
from folium import plugins
from jinja2 import Environment, FileSystemLoader
from markupsafe import Markup, escape
DOSSIER_TEMPLATES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates")
TUILES_SATELLITE = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'
DOSSIER_MYMAPS = "_mymaps"
SORTIE = "carte_globale.html"
# Une couleur par voyage, du plus récent au plus ancien.
COULEURS_VOYAGES = ['#E74C3C', '#2980B9', '#27AE60', '#8E44AD', '#F39C12',
'#16A085', '#D35400', '#2C3E50', '#C0392B', '#7F8C8D']
COULEUR_MYMAPS = '#8E44AD' # si le KML ne précise pas de couleur
# Écart toléré (m) en simplifiant les traces : à l'échelle de la France,
# 30 m sont invisibles et allègent beaucoup la page.
TOLERANCE_M = 30
env = Environment(loader=FileSystemLoader(DOSSIER_TEMPLATES), autoescape=True,
trim_blocks=True, lstrip_blocks=True)
def liens_et_lignes(texte):
"""Texte brut → HTML sûr : échappé, adresses http(s) cliquables, retours à la ligne."""
h = str(escape(texte))
h = re.sub(r'(https?://[^\s<]+)', r'<a href="\1" target="_blank" rel="noopener">\1</a>', h)
return Markup(h.replace('\n', '<br>'))
env.filters['liens_et_lignes'] = liens_et_lignes
F = env.get_template("fragments_globale.html").module
# --- Traces des voyages du site ---------------------------------------------
def eclaircir(points, ecart_m):
"""Ne garde que les points distants d'au moins ecart_m du précédent gardé
(calcul approché, rapide). Divise d'autant le travail de simplify(), lent
en Python sur les 100 000 points d'un grand voyage."""
if len(points) < 3:
return points
garde = [points[0]]
seuil = (ecart_m / 111_320.0) ** 2 # degrés², à peu près
for p in points[1:-1]:
q = garde[-1]
dlat = p.latitude - q.latitude
dlon = (p.longitude - q.longitude) * math.cos(math.radians(p.latitude))
if dlat * dlat + dlon * dlon >= seuil:
garde.append(p)
garde.append(points[-1])
return garde
def longueur_km(lignes):
total = 0.0
for l in lignes:
for (la1, lo1), (la2, lo2) in zip(l, l[1:]):
dlat = math.radians(la2 - la1)
dlon = math.radians(lo2 - lo1) * math.cos(math.radians((la1 + la2) / 2))
total += 6371.0 * math.hypot(dlat, dlon)
return total
def trace_voyage(dossier):
"""Lignes simplifiées [[lat, lon], …] d'un voyage et sa longueur en km.
Source : assets/trace.json (journées séparées, déjà simplifiées par
genere_carte.py) ; à défaut <voyage>.kml (une seule ligne : les journées
y sont reliées en ligne droite) ou <voyage>_complet.gpx (lent).
"""
nom = os.path.basename(dossier)
json_trace = os.path.join(dossier, 'assets', 'trace.json')
if os.path.exists(json_trace):
with open(json_trace, encoding='utf-8') as f:
lignes = json.load(f)
km = None
kml = os.path.join(dossier, f"{nom}.kml")
if os.path.exists(kml):
with open(kml, encoding='utf-8') as f:
m = re.search(r'(\d+) km<br>', f.read())
km = float(m.group(1)) if m else None
return lignes, km if km is not None else longueur_km(lignes)
kml = os.path.join(dossier, f"{nom}.kml")
gpx_complet = os.path.join(dossier, f"{nom}_complet.gpx")
brutes, km = [], None
if os.path.exists(kml):
with open(kml, encoding='utf-8') as f:
texte = f.read()
# Distance exacte calculée par genere_carte.py sur la trace complète
# (recalculée ici, elle compterait les sauts entre deux journées).
m = re.search(r'(\d+) km<br>', texte)
km = float(m.group(1)) if m else None
for bloc in re.findall(r'<coordinates>(.*?)</coordinates>', texte, flags=re.S):
pts = []
for c in bloc.split():
v = c.split(',')
pts.append(gpxpy.gpx.GPXTrackPoint(float(v[1]), float(v[0])))
brutes.append(pts)
elif os.path.exists(gpx_complet):
with open(gpx_complet, encoding='utf-8') as f:
gpx = gpxpy.parse(f)
brutes = [seg.points for t in gpx.tracks for seg in t.segments]
km = gpx.length_2d() / 1000.0
lignes = []
for pts in brutes:
s = gpxpy.gpx.GPXTrackSegment(eclaircir(pts, TOLERANCE_M / 2))
s.simplify(max_distance=TOLERANCE_M)
if len(s.points) > 1:
lignes.append([[round(p.latitude, 5), round(p.longitude, 5)] for p in s.points])
return lignes, km if km is not None else longueur_km(lignes)
# --- Cartes My Maps ----------------------------------------------------------
def mid_de(lien):
mid = parse_qs(urlparse(lien).query).get('mid')
return mid[0] if mid else None
def telecharger_mymaps(racine):
"""Récupère le KML des cartes listées dans mymaps.txt vers _mymaps/<mid>.kml
(une fois ; MYMAPS_MAJ=1 pour forcer)."""
liste = os.path.join(racine, "mymaps.txt")
if not os.path.exists(liste):
return
dossier = os.path.join(racine, DOSSIER_MYMAPS)
os.makedirs(dossier, exist_ok=True)
with open(liste, encoding='utf-8') as f:
liens = [l.strip() for l in f if l.strip() and not l.strip().startswith('#')]
for lien in liens:
mid = mid_de(lien)
if not mid:
print(f" ⚠️ mymaps.txt : pas un lien My Maps (mid=… manquant) : {lien}")
continue
dest = os.path.join(dossier, f"{mid}.kml")
if os.path.exists(dest) and os.environ.get("MYMAPS_MAJ") != "1":
continue
url = f"https://www.google.com/maps/d/kml?forcekml=1&mid={mid}"
contenu, erreur = telecharger(url)
if contenu is not None and b"<kml" not in contenu[:3000]:
contenu, erreur = None, ("Google a renvoyé une page au lieu de la carte : est-elle partagée "
"« Tous les utilisateurs disposant du lien » ?")
if contenu is None:
etat = "copie précédente utilisée" if os.path.exists(dest) else "ignorée"
print(f" ⚠️ My Maps {mid[:10]}… non récupérée — {erreur} ({etat})")
continue
with open(dest, 'wb') as f:
f.write(contenu)
print(f" 🌐 My Maps {mid[:10]}… récupérée dans {DOSSIER_MYMAPS}/ ({len(contenu) // 1024} Ko)")
def telecharger(url):
"""Renvoie (contenu, None) ou (None, message d'erreur).
Python d'abord, puis curl : le Python de python.org sous macOS n'a souvent
pas les certificats racine (CERTIFICATE_VERIFY_FAILED), curl utilise ceux
du système.
"""
try:
with urllib.request.urlopen(url, timeout=30) as r:
return r.read(), None
except Exception as e:
erreur = str(e)
if shutil.which("curl"):
r = subprocess.run(["curl", "-sSfL", "--max-time", "60", url], capture_output=True)
if r.returncode == 0 and r.stdout:
return r.stdout, None
erreur += f" ; curl : {r.stderr.decode(errors='replace').strip() or 'code ' + str(r.returncode)}"
return None, erreur
def lire_kml(chemin):
if chemin.lower().endswith('.kmz'):
with zipfile.ZipFile(chemin) as z:
nom = next((n for n in z.namelist() if n.lower().endswith('.kml')), None)
return z.read(nom) if nom else None
with open(chemin, 'rb') as f:
return f.read()
def couleur_kml(aabbggrr):
"""aabbggrr (KML) → #RRGGBB."""
c = aabbggrr.strip().lstrip('#')
return f"#{c[6:8]}{c[4:6]}{c[2:4]}".upper() if len(c) == 8 else None
def texte_simple(description):
"""Description My Maps → texte brut (balises retirées) ; le gabarit
l'échappe et rend les liens cliquables."""
if not description:
return ""
t = re.sub(r'<(script|style)\b.*?</\1>', '', description, flags=re.I | re.S)
t = re.sub(r'<br\s*/?>', '\n', t, flags=re.I)
t = re.sub(r'<[^>]+>', '', t)
return t.strip()
def nettoyer_nom(nom):
"""« Track-Pyrenees 2014 _ aller.kmz » → « Pyrenees 2014 – aller »."""
n = re.sub(r'\.km[lz]$', '', nom.strip(), flags=re.I)
n = re.sub(r'^(Track|T)\s*-\s*', '', n, flags=re.I)
n = re.sub(r'\s+_\s+|\s*:\s+', ' – ', n)
return re.sub(r'\s{2,}', ' ', n).strip()
def resumer_description(texte):
"""Les statistiques automatiques de My Maps (« Statistiques calculées… »,
altitudes à 0) sont réduites à la distance ; une vraie description est gardée."""
if texte.startswith("Statistiques calculées"):
m = re.search(r'Distance\s*:\s*([\d\s,.]+)\s*km', texte.replace('\xa0', ' '))
return f"{m.group(1).strip()} km" if m else ""
return texte
def elements_mymaps(contenu):
"""KML → (titre du document, {nom de calque: [éléments]}).
Élément : {'type': 'ligne'|'point', 'nom', 'description', 'couleur', 'coords'}.
"""
ns = {'k': 'http://www.opengis.net/kml/2.2'}
racine = ET.fromstring(contenu)
doc = racine.find('k:Document', ns)
if doc is None:
return None, {}
# Styles : id → couleur ; les StyleMap renvoient vers le style « normal ».
styles = {}
for st in doc.iter('{http://www.opengis.net/kml/2.2}Style'):
# (pas de « or » : un élément XML sans enfant est évalué comme faux)
c = st.find('k:LineStyle/k:color', ns)
if c is None:
c = st.find('k:IconStyle/k:color', ns)
if st.get('id') and c is not None and c.text:
styles['#' + st.get('id')] = couleur_kml(c.text)
for sm in doc.iter('{http://www.opengis.net/kml/2.2}StyleMap'):
for paire in sm.findall('k:Pair', ns):
if paire.findtext('k:key', '', ns) == 'normal':
styles['#' + (sm.get('id') or '')] = styles.get(paire.findtext('k:styleUrl', '', ns))
def coords(el):
pts = []
for c in (el.findtext('k:coordinates', '', ns) or '').split():
v = c.split(',')
if len(v) >= 2:
pts.append([round(float(v[1]), 5), round(float(v[0]), 5)])
return pts
calques = {}
def visiter(noeud, calque):
for enfant in noeud:
tag = enfant.tag.split('}')[-1]
if tag == 'Folder':
visiter(enfant, enfant.findtext('k:name', calque, ns))
elif tag == 'Placemark':
base = {'nom': nettoyer_nom(enfant.findtext('k:name', '', ns)),
'description': resumer_description(texte_simple(enfant.findtext('k:description', '', ns))),
'couleur': styles.get(enfant.findtext('k:styleUrl', '', ns)) or COULEUR_MYMAPS}
for ls in enfant.iter('{http://www.opengis.net/kml/2.2}LineString'):
pts = coords(ls)
if len(pts) > 1:
calques.setdefault(calque, []).append(dict(base, type='ligne', coords=pts))
for pt in enfant.iter('{http://www.opengis.net/kml/2.2}Point'):
pts = coords(pt)
if pts:
calques.setdefault(calque, []).append(dict(base, type='point', coords=pts[0]))
titre = doc.findtext('k:name', 'My Maps', ns)
visiter(doc, titre)
return titre, calques
def simplifier(coords):
pts = [gpxpy.gpx.GPXTrackPoint(la, lo) for la, lo in coords]
seg = gpxpy.gpx.GPXTrackSegment(eclaircir(pts, TOLERANCE_M / 2))
seg.simplify(max_distance=TOLERANCE_M)
return [[round(p.latitude, 5), round(p.longitude, 5)] for p in seg.points]
# --- Carte -------------------------------------------------------------------
def generer_carte_globale(racine, rubriques):
"""rubriques : liste de {'titre', 'voyages': [{'titre', 'chemin', 'periode', 'dossier'}]}."""
racine = os.path.abspath(racine)
telecharger_mymaps(racine)
m = folium.Map(tiles=None, scrollWheelZoom=False)
folium.TileLayer('OpenStreetMap', name='Plan').add_to(m)
folium.TileLayer(TUILES_SATELLITE, attr='Esri', name='Satellite', show=False).add_to(m)
tous = []
resume = []
# Voyages du site : un calque par voyage, coloré, que la légende permet
# d'afficher ou de masquer ; le plus récent en tête de légende.
voyages = [v for r in rubriques for v in r['voyages']]
calques = []
for i, v in enumerate(voyages):
lignes, km = trace_voyage(v['dossier'])
if not lignes:
continue
couleur = COULEURS_VOYAGES[i % len(COULEURS_VOYAGES)]
libelle = f"{v['titre']} ({v['moto']})" if v.get('moto') else v['titre']
groupe = folium.FeatureGroup(name=str(F.legende(libelle, couleur)))
popup = str(F.popup_voyage(libelle, v['periode'], km, f"{v['chemin']}/index.html"))
for l in lignes:
folium.PolyLine(l, color=couleur, weight=4, opacity=0.85, tooltip=libelle,
popup=folium.Popup(popup, max_width=260)).add_to(groupe)
tous.extend(l)
calques.append(groupe)
for groupe in calques:
groupe.add_to(m)
resume.append(f"{len(calques)} voyage(s)")
# Cartes My Maps
dossier = os.path.join(racine, DOSSIER_MYMAPS)
fichiers = sorted(f for f in os.listdir(dossier) if f.lower().endswith(('.kml', '.kmz'))) \
if os.path.isdir(dossier) else []
for f in fichiers:
try:
titre, calques = elements_mymaps(lire_kml(os.path.join(dossier, f)))
except Exception as e:
print(f" ⚠️ {DOSSIER_MYMAPS}/{f} illisible : {e}")
continue
# Calques du plus récent au plus ancien (leurs noms commencent par l'année).
for nom_calque, elements in sorted(calques.items(), key=lambda kv: kv[0], reverse=True):
couleur_calque = elements[0]['couleur'] if elements else COULEUR_MYMAPS
groupe = folium.FeatureGroup(name=str(F.legende(nom_calque, couleur_calque)))
for e in elements:
popup = folium.Popup(str(F.popup_mymaps(e['nom'], e['description'])), max_width=300)
if e['type'] == 'ligne':
pts = simplifier(e['coords'])
folium.PolyLine(pts, color=e['couleur'], weight=4, opacity=0.85,
tooltip=e['nom'] or None, popup=popup).add_to(groupe)
tous.extend(pts)
else:
folium.CircleMarker(e['coords'], radius=6, color='#fff', weight=2,
fill=True, fill_color=e['couleur'], fill_opacity=1,
tooltip=e['nom'] or None, popup=popup).add_to(groupe)
tous.append(e['coords'])
groupe.add_to(m)
resume.append(f"{nom_calque} : {len(elements)}")
if tous:
lats = [p[0] for p in tous]
lons = [p[1] for p in tous]
m.fit_bounds([[min(lats), min(lons)], [max(lats), max(lons)]], padding=(20, 20))
folium.LayerControl(collapsed=False).add_to(m)
plugins.Fullscreen(position='topleft').add_to(m)
m.get_root().html.add_child(folium.Element(str(F.bouton_accueil())))
# Molette active seulement quand la carte est ouverte seule : intégrée dans
# l'accueil, elle capterait le défilement de la page.
m.get_root().script.add_child(folium.Element(str(F.script_page(m.get_name()))))
m.save(os.path.join(racine, SORTIE))
print(f"🗺️ Carte globale : {SORTIE} ({', '.join(resume)})")
if __name__ == "__main__":
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from genere_index_general import TITRE_SECTION_RACINE, collecter_rubriques
racine = sys.argv[1] if len(sys.argv) > 1 else "."
generer_carte_globale(racine, [r for r in collecter_rubriques(os.path.abspath(racine))
if r['titre'] == TITRE_SECTION_RACINE])