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:
1 parent
97b936fd3e
commit
f0e1c2dfce
23 files changed
+3083
-1
No files matched your search
@@ -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)})")
|
||||
Reference in new issue
Block a user