docs: publicar el documento de alcance como pagina web privada en /doc/alcance-2f9k7d/

- scripts/generar-web-documento.py arma la pagina (barra con logotipo, boton de
  descarga del PDF, hoja centrada, tablas con scroll en pantallas chicas, modo
  oscuro automatico y estilos de impresion) y reutiliza el generador de PDF.
- La pagina se sirve con un prefijo privado en NGINX: no se enlaza desde ninguna
  app, lleva X-Robots-Tag y meta noindex, y la cache se desactiva para que los
  cambios se vean al momento.
- Se sincroniza deploy/nginx-host.conf con lo que estaba instalado en el host
  (faltaba el cache de assets immutable) y se agrega la ruta privada.
- El logotipo se reduce a tamano de pantalla: venia de un JPEG de 959 px y era el
  90% del peso del PDF (495 KB -> 240 KB), sin cambiar el diseno.
This commit is contained in:
2026-09-21 19:16:48 +00:00
parent 31abeec362
commit e8d1c57ad5
7 changed files with 1631 additions and 4 deletions

View File

@@ -4,8 +4,8 @@
# index.html siempre se revalida (los assets de Vite llevan hash en el nombre, # index.html siempre se revalida (los assets de Vite llevan hash en el nombre,
# así que solo el HTML necesita no-cache para que los deploys se vean al momento) # así que solo el HTML necesita no-cache para que los deploys se vean al momento)
map $uri $frasa_cache_control { map $uri $frasa_cache_control {
~*\.html$ "no-cache"; ~*\.html$ "no-cache, no-store, must-revalidate";
default ""; default "public, max-age=31536000, immutable";
} }
server { server {
@@ -56,6 +56,17 @@ server {
try_files $uri $uri/ /hoy/index.html; try_files $uri $uri/ /hoy/index.html;
} }
# Documento entregable publicado como página web privada.
# NO se enlaza desde ninguna app: sólo se llega con el enlace exacto, y lleva
# noindex para que no lo indexe ningún buscador. Se regenera con
# scripts/generar-web-documento.py
location /doc/alcance-2f9k7d/ {
alias /root/frasa-ecosystem/docs/web/alcance-2f9k7d/;
index index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header X-Robots-Tag "noindex, nofollow, noarchive" always;
}
# Landing (raíz) # Landing (raíz)
location / { location / {
root /root/frasa-ecosystem/apps/landing/dist; root /root/frasa-ecosystem/apps/landing/dist;

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

View File

@@ -38,14 +38,22 @@ COLOR_FONDO_LOGO = (247, 247, 247)
TOLERANCIA_FONDO = 8 TOLERANCIA_FONDO = 8
DIMENSION_LOGO = (480, 360)
def logo_con_transparencia() -> pathlib.Path: def logo_con_transparencia() -> pathlib.Path:
"""El logo del repo trae fondo gris; se aplana a alfa 0 desde los bordes. """El logo del repo trae fondo gris; se aplana a alfa 0 desde los bordes.
Se rellena solo desde el exterior para no borrar los grises interiores del Se rellena solo desde el exterior para no borrar los grises interiores del
arte (contrafuertes de letras). Sin transparencia, la portada blanca dibuja arte (contrafuertes de letras). Sin transparencia, la portada blanca dibuja
un rectángulo gris alrededor del logotipo. un rectángulo gris alrededor del logotipo.
La salida se reduce a `DIMENSION_LOGO`: el original de 959 px pesa 440 KB
(viene de un JPEG, con ruido que el PNG no comprime) y se dibuja a 24 mm, así
que sobra resolución. El nombre del archivo incluye el tamaño para no reusar
una versión vieja con otras medidas.
""" """
destino = pathlib.Path(tempfile.gettempdir()) / 'frasa-logo-transparente.png' destino = pathlib.Path(tempfile.gettempdir()) / f'frasa-logo-transparente-{DIMENSION_LOGO[0]}x{DIMENSION_LOGO[1]}.png'
if destino.exists(): if destino.exists():
return destino return destino
@@ -83,7 +91,8 @@ def logo_con_transparencia() -> pathlib.Path:
if y < alto - 1: if y < alto - 1:
cola.append((x, y + 1)) cola.append((x, y + 1))
imagen.save(destino) imagen.thumbnail(DIMENSION_LOGO)
imagen.save(destino, optimize=True)
return destino return destino

View File

@@ -0,0 +1,265 @@
#!/usr/bin/env python3
"""Publica un documento de `docs/` como página web no listada.
Lee el mismo markdown que el generador de PDF, arma una página con el diseño
institucional (barra superior con logotipo, hoja blanca, tablas adaptables y modo
oscuro) y deja junto a ella el PDF para descargar. El resultado se sirve por
NGINX desde una ruta privada: no se enlaza desde ninguna app, así que sólo se
llega con el enlace.
Uso:
/root/.venv-tools/bin/python scripts/generar-web-documento.py \\
docs/ALCANCE-VS-ENTREGADO.md --slug alcance-2f9k7d
Salida: docs/web/<slug>/index.html + docs/web/<slug>/<documento>.pdf + logo.png
Requiere weasyprint, markdown y Pillow, que en esta máquina viven en el entorno
aislado /root/.venv-tools (no en el python del sistema).
"""
from __future__ import annotations
import argparse
import html as html_mod
import importlib.util
import pathlib
import re
import shutil
import subprocess
import sys
import markdown
RAIZ = pathlib.Path(__file__).resolve().parent.parent
AQUI = pathlib.Path(__file__).resolve().parent
SCRIPT_PDF = AQUI / 'generar-pdf-documento.py'
PROVEEDOR = 'Consult (AS) Technology'
def cargar_generador_pdf():
"""Importa el generador de PDF (el nombre del archivo lleva guiones)."""
especificacion = importlib.util.spec_from_file_location('generador_pdf', SCRIPT_PDF)
if especificacion is None or especificacion.loader is None:
raise SystemExit(f'No se pudo cargar {SCRIPT_PDF}')
modulo = importlib.util.module_from_spec(especificacion)
especificacion.loader.exec_module(modulo)
return modulo
CSS_WEB = """
:root {
--marca: #023681;
--acento: #1B4332;
--acento-claro: #2D6A4F;
--texto: #1F2937;
--texto-suave: #6B7280;
--fondo: #EEF1F0;
--hoja: #FFFFFF;
--borde: #DCE1E8;
--zebra: #F6F8F7;
}
@media (prefers-color-scheme: dark) {
:root {
--marca: #7FA8E8;
--acento: #4ADE9B;
--acento-claro: #6EE7B7;
--texto: #E2E8F0;
--texto-suave: #94A3B8;
--fondo: #0B1220;
--hoja: #131C2B;
--borde: #2A3648;
--zebra: #182233;
}
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
background: var(--fondo);
color: var(--texto);
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 16px;
line-height: 1.65;
}
.barra {
position: sticky; top: 0; z-index: 10;
display: flex; align-items: center; gap: 16px;
padding: 12px max(20px, calc((100vw - 1040px) / 2));
background: var(--hoja);
border-bottom: 3px solid var(--acento);
}
.barra img { height: 38px; display: block; }
.barra .rotulo { display: flex; flex-direction: column; line-height: 1.25; margin-right: auto; }
.barra .rotulo strong { font-size: 15px; letter-spacing: .2px; }
.barra .rotulo span { font-size: 12.5px; color: var(--texto-suave); }
@media (max-width: 640px) { .barra .rotulo span { display: none; } }
.barra a.boton {
display: inline-flex; align-items: center; gap: 8px;
padding: 9px 16px; border-radius: 999px;
background: var(--marca); color: #fff; text-decoration: none;
font-size: 14px; font-weight: 600; white-space: nowrap;
}
.barra a.boton:hover { filter: brightness(1.12); }
@media (prefers-color-scheme: dark) { .barra a.boton { color: #0B1220; } }
main { max-width: 1040px; margin: 28px auto 56px; padding: 0 20px; }
.hoja {
background: var(--hoja);
border: 1px solid var(--borde);
border-radius: 14px;
padding: 44px 52px 36px;
box-shadow: 0 1px 3px rgba(15, 23, 42, .06), 0 12px 32px -18px rgba(15, 23, 42, .25);
}
@media (max-width: 720px) { .hoja { padding: 26px 20px; border-radius: 10px; } }
.sello {
display: inline-block; margin: 0 0 18px;
padding: 5px 12px; border-radius: 999px;
background: var(--zebra); border: 1px solid var(--borde);
font-size: 12px; letter-spacing: .4px; text-transform: uppercase; color: var(--texto-suave);
}
h1 { font-size: 30px; line-height: 1.22; margin: 0 0 6px; color: var(--acento); letter-spacing: -.4px; }
p.bajada { font-size: 17px; color: var(--texto-suave); margin: 0 0 26px; }
.ficha { border: 1px solid var(--borde); border-radius: 10px; overflow: hidden; margin: 0 0 34px; }
.ficha div { display: flex; gap: 16px; padding: 10px 16px; border-bottom: 1px solid var(--borde); }
.ficha div:last-child { border-bottom: none; }
.ficha div:nth-child(odd) { background: var(--zebra); }
.ficha dt {
flex: 0 0 210px; margin: 0; font-size: 12px; letter-spacing: .5px;
text-transform: uppercase; color: var(--texto-suave); padding-top: 3px;
}
.ficha dd { margin: 0; font-weight: 600; font-size: 14.5px; }
@media (max-width: 620px) { .ficha div { display: block; } .ficha dt { margin-bottom: 2px; } }
h2 {
font-size: 21px; color: var(--acento); margin: 44px 0 14px;
padding-bottom: 8px; border-bottom: 2px solid var(--acento-claro);
scroll-margin-top: 80px;
}
h3 { font-size: 17px; color: var(--acento-claro); margin: 30px 0 10px; }
p { margin: 0 0 14px; }
ul, ol { margin: 0 0 16px; padding-left: 24px; }
li { margin-bottom: 6px; }
strong { color: var(--acento); }
@media (prefers-color-scheme: dark) { strong { color: var(--texto); } }
hr { border: none; border-top: 1px solid var(--borde); margin: 34px 0; }
.tabla-scroll { overflow-x: auto; margin: 16px 0 26px; border: 1px solid var(--borde); border-radius: 10px; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th {
background: var(--acento); color: #fff; text-align: left;
padding: 10px 12px; font-size: 13px; font-weight: 600; white-space: nowrap;
}
td { padding: 9px 12px; border-top: 1px solid var(--borde); vertical-align: top; }
tbody tr:nth-child(even) td { background: var(--zebra); }
td:first-child { white-space: nowrap; }
@media (max-width: 620px) { td:first-child { white-space: normal; } }
footer.pie {
max-width: 1040px; margin: 0 auto 40px; padding: 0 20px;
font-size: 13px; color: var(--texto-suave); text-align: center;
}
@media print {
body { background: #fff; }
.barra { position: static; border-bottom: 1px solid #ccc; }
.barra a.boton { display: none; }
main { max-width: none; margin: 0; padding: 0; }
.hoja { border: none; box-shadow: none; padding: 0; }
h2 { break-after: avoid; }
.tabla-scroll { overflow: visible; border: none; }
table { font-size: 11pt; }
tr { break-inside: avoid; }
}
"""
def envolver_tablas(cuerpo: str) -> str:
"""Las tablas se envuelven para poder desplazarse en pantallas chicas."""
cuerpo = cuerpo.replace('<table>', '<div class="tabla-scroll"><table>')
return cuerpo.replace('</table>', '</table></div>')
def ficha_html(datos: list[tuple[str, str]]) -> str:
if not datos:
return ''
filas = '\n'.join(
f' <div><dt>{html_mod.escape(etiqueta)}</dt><dd>{html_mod.escape(valor)}</dd></div>'
for etiqueta, valor in datos
)
return f' <dl class="ficha">\n{filas}\n </dl>\n'
def main() -> None:
parser = argparse.ArgumentParser(description='Publica un documento de docs/ como página web')
parser.add_argument('markdown', type=pathlib.Path, help='ruta del .md de entrada')
parser.add_argument('--slug', required=True, help='carpeta de la ruta privada (p. ej. alcance-2f9k7d)')
parser.add_argument('--salida', type=pathlib.Path, help='carpeta destino (por defecto docs/web/<slug>)')
args = parser.parse_args()
generador = cargar_generador_pdf()
origen = args.markdown.resolve()
destino = args.salida or RAIZ / 'docs' / 'web' / args.slug
destino.mkdir(parents=True, exist_ok=True)
titulo, subtitulo, datos, cuerpo_md = generador.partir_encabezado(origen.read_text(encoding='utf-8'))
principal, _, secundario = titulo.partition(' — ')
# El PDF es la versión para imprimir y compartir: se genera aquí mismo.
# Conserva el nombre del markdown de origen (sin prefijo duplicado del título).
nombre_pdf = f'{origen.stem}.pdf'
ruta_pdf = destino / nombre_pdf
subprocess.run(
[sys.executable, str(SCRIPT_PDF), str(origen), '--salida', str(ruta_pdf)],
check=True,
capture_output=True,
)
logo_destino = destino / 'logo.png'
shutil.copy2(generador.logo_con_transparencia(), logo_destino)
cuerpo = envolver_tablas(markdown.markdown(cuerpo_md, extensions=['tables', 'sane_lists', 'attr_list']))
anio = next((v for k, v in datos if 'fecha' in k.lower()), '')
pie = f'{PROVEEDOR} · {anio}' if anio else PROVEEDOR
pagina = f"""<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet" />
<meta name="color-scheme" content="light dark" />
<title>{html_mod.escape(principal)} — {html_mod.escape(secundario or subtitulo)}</title>
<link rel="icon" href="logo.png" />
<style>{CSS_WEB}</style>
</head>
<body>
<header class="barra">
<img src="logo.png" alt="CRM360 FRASA" />
<div class="rotulo">
<strong>{html_mod.escape(principal)}</strong>
<span>{html_mod.escape(secundario or subtitulo)}</span>
</div>
<a class="boton" href="{nombre_pdf}" download>Descargar PDF</a>
</header>
<main>
<article class="hoja">
<p class="sello">Documento confidencial · Preparado para el cliente</p>
<h1>{html_mod.escape(principal)}</h1>
<p class="bajada">{html_mod.escape(secundario or subtitulo)}</p>
{ficha_html(datos)}{cuerpo}
</article>
</main>
<footer class="pie">Documento preparado por {PROVEEDOR}</footer>
</body>
</html>
"""
(destino / 'index.html').write_text(pagina, encoding='utf-8')
print(f'Página generada: {destino / "index.html"} ({(destino / "index.html").stat().st_size} bytes)')
print(f'PDF para descarga: {ruta_pdf} ({ruta_pdf.stat().st_size} bytes)')
if __name__ == '__main__':
main()