5af86e3518
Durcissement d'uniformité (pas bug déclenchable aujourd'hui) qui COMPLÈTE le fix
correctness de la session 070054 (aeb30b8). Ce fix-là a percent-encodé
hero['fichier'] dans src=f"/static/projets/{code}/{quote(fichier)}", mais dans la
MÊME url('…') CSS il restait un 2ᵉ segment dynamique — `code` — interpolé brut puis
html-échappé via _esc(src). C'est exactement l'anti-pattern identifié par 070054
(html.escape = mauvais encodeur pour un segment d'URL), appliqué de façon incomplète.
Fix : src = f"/static/projets/{quote(code)}/{quote(hero['fichier'])}" — quote() est
le SEUL encodeur correct pour un segment d'URL ; le CONTEXTE, pas la donnée courante,
dicte l'encodeur. Zéro dérive : codes réels = P01..P09 (§Projets), quote("p01")=="p01"
→ byte-repro inchangé. Cas frère exact de 060044/criteria.py (« durcir le SEUL membre
non-durci d'une famille ») : ici le SEUL segment d'URL non-encodé de la même url().
Test à dents (test_hero_media_code_segment_percent_encoded) : code théorique "P'X" →
prouvé FAIL sans quote(code), PASS avec. Suite publiciste 25→26, matrice 630/613→631/614.
Défaut d'abord : classe encodage-contextuel balayée sur tous les modules HTML/CSS/URL ;
2 candidats Explore réfutés non-défauts (chat mount_html = persona forcée Amélie par
invariant 7 + portail_key enum ; code segment = théorique mais durci par uniformité).
Cascade régénérée dans l'ordre (quality_report en DERNIER) · 3 fiches compteurs réalignées.
run_ci 33/0/0 · NFC-clean · zéro code moteur V18 (bloqué D-06 #6) · zéro gate (#5) · zéro invention (#6).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
174 lines
6.9 KiB
Python
174 lines
6.9 KiB
Python
"""Générateur HTML · projets_master.json → site public (vente.otov7.com).
|
|
|
|
Livrable Publiciste · Sprint 2 · Semaine 3 (aperçu ; le parser Semaine 2 est le
|
|
cœur du sprint courant). Rendu server-side pur stdlib, marque luxury (#4).
|
|
|
|
Interdits appliqués (AGENT.md §Règles absolues) :
|
|
- ❌ Jamais éditer directement l'index publié : on passe TOUJOURS par ce
|
|
générateur, qui écrit une sortie complète et horodatée.
|
|
- ❌ Zéro prix inventé : un projet sans prix numérique n'affiche PAS de grille,
|
|
mais « Prochainement · Détails à venir » (contrainte #6).
|
|
- ❌ Rendus IA génériques interdits : on n'affiche que les rendus référencés
|
|
dans la faisabilité (liés au projet réel).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import html
|
|
import os
|
|
from typing import Any
|
|
from urllib.parse import quote
|
|
|
|
from . import branding
|
|
|
|
_TEMPLATE = os.path.join(os.path.dirname(__file__), "..", "templates", "site_public.html.tmpl")
|
|
|
|
_TAGLINE = "Résidences d'exception en République dominicaine · faisabilités 4 volets."
|
|
_A_VENIR = "Prochainement · Détails à venir"
|
|
|
|
|
|
def _esc(text: Any) -> str:
|
|
return html.escape(str(text), quote=True)
|
|
|
|
|
|
def _fmt_usd(value: float) -> str:
|
|
return f"USD {value:,.0f}".replace(",", " ")
|
|
|
|
|
|
def _fmt_dop(value: float) -> str:
|
|
return f"DOP {value:,.0f}".replace(",", " ")
|
|
|
|
|
|
def _fmt_m2(value: float) -> str:
|
|
txt = f"{value:,.0f}".replace(",", " ") if float(value).is_integer() else f"{value:,.1f}".replace(",", " ")
|
|
return f"{txt} m²"
|
|
|
|
|
|
def _prix_depuis(typologies: list[dict[str, Any]]) -> float | None:
|
|
"""Prix « à partir de » du projet = min des prix USD numériques présents."""
|
|
prices = [t["prix_depuis_usd"] for t in typologies if isinstance(t.get("prix_depuis_usd"), (int, float))]
|
|
return min(prices) if prices else None
|
|
|
|
|
|
def _render_typologies(typologies: list[dict[str, Any]]) -> str:
|
|
rows = []
|
|
for t in typologies:
|
|
surface = t.get("surface_totale_m2") or t.get("surface_interieure_m2")
|
|
surface_txt = _fmt_m2(surface) if isinstance(surface, (int, float)) else "—"
|
|
prix = t.get("prix_depuis_usd")
|
|
prix_txt = (
|
|
f'<span class="prix-depuis">{_esc(_fmt_usd(prix))}</span>'
|
|
if isinstance(prix, (int, float))
|
|
else "—"
|
|
)
|
|
rows.append(
|
|
" <tr>"
|
|
f"<td>{_esc(t.get('nom', ''))}</td>"
|
|
f"<td>{surface_txt}</td>"
|
|
f"<td>{prix_txt}</td>"
|
|
"</tr>"
|
|
)
|
|
return (
|
|
' <table class="prix">\n'
|
|
" <tr><th>Typologie</th><th>Surface</th><th>À partir de</th></tr>\n"
|
|
+ "\n".join(rows)
|
|
+ "\n </table>"
|
|
)
|
|
|
|
|
|
def _render_inclus(inclus: list[str]) -> str:
|
|
if not inclus:
|
|
return ""
|
|
items = "\n".join(f" <li>{_esc(x)}</li>" for x in inclus)
|
|
return f' <ul class="inclus">\n{items}\n </ul>'
|
|
|
|
|
|
def _hero_media(projet: dict[str, Any]) -> str:
|
|
rendus = projet.get("rendus", [])
|
|
hero = next((r for r in rendus if r.get("hero")), rendus[0] if rendus else None)
|
|
if not hero:
|
|
return ' <div class="media"></div>'
|
|
# Convention de chemin public (miroir de /opt/oto/sites/static/projets/pXX/).
|
|
# Le nom de rendu réel peut contenir espaces/apostrophes/accents (« Vue
|
|
# d'ensemble.png », « façade nord.webp ») : on percent-encode CHAQUE segment
|
|
# dynamique de l'URL (code ET fichier) pour qu'elle reste valide DANS le
|
|
# url('…') CSS. Sans cela, une apostrophe — une fois l'attribut style
|
|
# HTML-décodé par le navigateur — referme la chaîne CSS et casse
|
|
# silencieusement le background-image. quote() est le SEUL encodeur correct
|
|
# pour un segment d'URL (html.escape ne l'est pas) : `code` est un identifiant
|
|
# contrôlé (P01..P09) aujourd'hui, mais on l'encode par uniformité de
|
|
# construction — le contexte, pas la donnée courante, dicte l'encodeur.
|
|
# (_esc protège l'attribut HTML ; quote protège le contexte CSS interne.)
|
|
code = projet["code"].lower()
|
|
src = f"/static/projets/{quote(code)}/{quote(hero['fichier'])}"
|
|
style = f"background-image:url('{_esc(src)}')"
|
|
return f' <div class="media" style="{style}" role="img" aria-label="{_esc(projet.get("nom",""))}"></div>'
|
|
|
|
|
|
def render_projet(projet: dict[str, Any]) -> str:
|
|
statut = projet.get("statut", "en_developpement")
|
|
statut_label = branding.statut_label(statut)
|
|
nom = projet.get("nom", projet.get("code", ""))
|
|
loc = projet.get("localisation", "")
|
|
|
|
parts = [f' <article class="projet" data-code="{_esc(projet.get("code",""))}">']
|
|
parts.append(_hero_media(projet))
|
|
parts.append(' <div class="corps">')
|
|
parts.append(f' <span class="statut {_esc(statut)}">{_esc(statut_label)}</span>')
|
|
parts.append(f" <h2>{_esc(nom)}</h2>")
|
|
if loc:
|
|
parts.append(f' <p class="localisation">{_esc(loc)}</p>')
|
|
|
|
positionnement = (projet.get("positionnement") or {}).get("fr")
|
|
if positionnement:
|
|
parts.append(f' <p class="positionnement">{_esc(positionnement)}</p>')
|
|
|
|
typologies = projet.get("typologies", [])
|
|
show_prices = statut not in branding.STATUTS_SANS_PRIX and _prix_depuis(typologies) is not None
|
|
if show_prices:
|
|
parts.append(_render_typologies(typologies))
|
|
else:
|
|
# Aucun prix inventé — message d'attente (contrainte #6).
|
|
parts.append(f' <p class="a-venir">{_esc(_A_VENIR)}</p>')
|
|
|
|
inclus_html = _render_inclus(projet.get("inclus", []))
|
|
if inclus_html:
|
|
parts.append(inclus_html)
|
|
|
|
parts.append(" </div>")
|
|
parts.append(" </article>")
|
|
return "\n".join(parts)
|
|
|
|
|
|
def render_site(master: dict[str, Any]) -> str:
|
|
"""projets_master.json (dict) → page HTML complète (str)."""
|
|
with open(_TEMPLATE, encoding="utf-8") as fh:
|
|
tmpl = fh.read()
|
|
|
|
projets_html = "\n".join(render_projet(p) for p in master.get("projets", []))
|
|
generated_at = master.get("generated_at", "")
|
|
template_version = master.get("template_version", "")
|
|
footer = (
|
|
f"Contenu généré automatiquement depuis les faisabilités canoniques · "
|
|
f"template {_esc(template_version)} · {_esc(generated_at)}. "
|
|
"Prix « à partir de » en USD + DOP (Cardnet). Aucune donnée inventée — "
|
|
"chaque valeur provient de la faisabilité du projet."
|
|
)
|
|
|
|
replacements = {
|
|
"{{TITRE}}": "Helios RD · Résidences d'exception",
|
|
"{{META_DESCRIPTION}}": _TAGLINE,
|
|
"{{TAGLINE}}": _TAGLINE,
|
|
"{{COLOR_BG}}": branding.COLOR_BG,
|
|
"{{COLOR_ACCENT}}": branding.COLOR_ACCENT,
|
|
"{{COLOR_INK}}": branding.COLOR_INK,
|
|
"{{COLOR_MUTED}}": branding.COLOR_MUTED,
|
|
"{{FONT_DISPLAY}}": branding.FONT_DISPLAY,
|
|
"{{FONT_BODY}}": branding.FONT_BODY,
|
|
"{{PROJETS}}": projets_html,
|
|
"{{FOOTER}}": footer,
|
|
}
|
|
for key, val in replacements.items():
|
|
tmpl = tmpl.replace(key, val)
|
|
return tmpl
|