Phase 1 : moteur Python et commande rtg

- Paquet roadtripsgenerator (pyproject.toml), commande rtg :
  generer, etat, analyser, compresser, accueil, migrer
- Réglages site.toml / voyage.toml (anciens mymaps.txt et moto.txt
  encore lus) ; qualité vidéo en trois crans
- Sources laissées en place : traces découpées par journée d'après
  leurs horodatages (coupure à 4 h) ou la date du nom de fichier,
  photos copiées, HEIC converties en JPEG, position déduite de la
  trace pour les photos sans GPS
- extraction_infos.sh et compress.sh portés en Python (plus de bash) ;
  sorties vidéo écrites sous un nom temporaire ; positions des rushs
  inchangés reprises du dernier export ; export vidéo conservé quand
  le disque des rushs est débranché
- Pages, accueil et carte globale repris dans le paquet : sortie
  identique aux scripts (123 fichiers comparés sur 7 voyages)
- Tests pytest sur un voyage d'exemple fabriqué à la volée

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim
This commit is contained in:
FabriceandClaude Opus 5.5 committed 2026-10-09 11:31:31 +00:00
1 parent 97b936fd3e
commit f0e1c2dfce
23 files changed
+3083 -1

No files matched your search

+379
View File
@@ -0,0 +1,379 @@
"""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 :
- réglage carte_globale.mymaps de site.toml (ou ancien mymaps.txt) : liens de
cartes My Maps partagées « 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 rtg.accueil.
"""
import json
import math
import os
import re
import shutil
import subprocess
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, liens, mettre_a_jour=False):
"""Récupère le KML des cartes My Maps vers _mymaps/<mid>.kml (une fois ;
mettre_a_jour=True ou MYMAPS_MAJ=1 pour forcer)."""
if not liens:
return
dossier = os.path.join(racine, DOSSIER_MYMAPS)
os.makedirs(dossier, exist_ok=True)
for lien in liens:
mid = mid_de(lien)
if not mid:
print(f" ⚠️ Pas un lien My Maps (mid=… manquant) : {lien}")
continue
dest = os.path.join(dossier, f"{mid}.kml")
if os.path.exists(dest) and not mettre_a_jour 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, liens_mymaps=(), mettre_a_jour=False):
"""rubriques : liste de {'titre', 'voyages': [{'titre', 'chemin', 'periode', 'dossier', 'moto'}]}."""
racine = os.path.abspath(racine)
telecharger_mymaps(racine, list(liens_mymaps), mettre_a_jour)
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)})")