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

@@ -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()