26s/tools/genera.py
hacklab cb551e2a3c Contenido desde OASIS y colectivos
Comunicados, comisiones y debates salen del pub: los publica quien es
miembro de la tribu de la acampada y además sigue la cuenta de la
acampada. Directo enlaza al HUB. Lista de centros sociales y colectivos
de Madrid con enlaces comprobados.
2026-09-29 10:25:28 +02:00

193 lines
6.4 KiB
Python
Executable file

#!/usr/bin/env python3
"""Escribe en la web lo que sale de tools/extrae.js.
Lee el JSON por stdin y, dentro de SITE:
- rellena en index.html los bloques entre <!-- X:inicio --> y <!-- X:fin -->
para X = comunicados, comisiones, debates
- crea comunicados/AAAA-MM-DD-xxxxxxxx.html por comunicado y borra los que
ya no están (borrados en OASIS o de alguien que ya no publica)
Todo el texto que viene de SSB se escapa. Escritura atómica: nginx nunca sirve
un fichero a medias.
node tools/extrae.js | python3 tools/genera.py /var/www/acampada26s.net
"""
import hashlib
import html
import json
import os
import re
import sys
import tempfile
from datetime import datetime
from urllib.parse import quote
from zoneinfo import ZoneInfo
TZ = ZoneInfo("Europe/Madrid")
ETIQUETA = re.compile(r"#comunicado26s\b", re.I)
IMG = re.compile(r"!\[[^\]]*\]\((&[A-Za-z0-9+/=]+\.sha256)\)")
ENLACE = re.compile(r"\[([^\]]+)\]\((https?://[^\s)]+)\)")
NOMBRE = re.compile(r"^\d{4}-\d{2}-\d{2}-[0-9a-f]{8}\.html$")
EXTRACTO = 240
def fecha(ts):
return datetime.fromtimestamp(ts / 1000, tz=TZ)
def blob(ref):
return "/c/blob/" + quote(ref, safe="")
def plano(texto):
"""Texto sin markdown de imágenes/enlaces ni la etiqueta, en una línea."""
texto = IMG.sub("", texto)
texto = ENLACE.sub(r"\1", texto)
texto = ETIQUETA.sub("", texto)
return " ".join(texto.split())
def partir(texto):
"""Primera línea con contenido = título; el resto = cuerpo."""
lineas = ETIQUETA.sub("", texto).replace("\r", "").split("\n")
for i, l in enumerate(lineas):
t = plano(l).lstrip("# ").strip()
if t:
return t[:120], "\n".join(lineas[i + 1:])
return "Comunicado", ""
def cuerpo(texto):
"""Markdown mínimo: párrafos, saltos, imágenes del pub y enlaces http(s)."""
out = []
for parrafo in re.split(r"\n\s*\n", texto.replace("\r", "").strip()):
if not parrafo.strip():
continue
trozos, pos = [], 0
for m in re.finditer(f"{IMG.pattern}|{ENLACE.pattern}", parrafo):
trozos.append(html.escape(parrafo[pos:m.start()]))
if m.group(1):
trozos.append(f'<img src="{html.escape(blob(m.group(1)))}" alt="" loading="lazy">')
else:
trozos.append(f'<a href="{html.escape(m.group(3))}" rel="noopener noreferrer">'
f"{html.escape(m.group(2))}</a>")
pos = m.end()
trozos.append(html.escape(parrafo[pos:]))
p = "".join(trozos).strip().replace("\n", "<br>\n")
out.append(f" <p>{p}</p>")
return "\n".join(out)
def nombre(c):
h = hashlib.sha256(c["key"].encode()).hexdigest()[:8]
return f"{fecha(c['ts']):%Y-%m-%d}-{h}.html"
def cambia(doc, clave, contenido):
ini, fin = f"<!-- {clave}:inicio -->", f"<!-- {clave}:fin -->"
i, j = doc.find(ini), doc.find(fin)
if i < 0 or j < i:
sys.exit(f"faltan las marcas {clave}:inicio / {clave}:fin")
return doc[:i + len(ini)] + "\n" + contenido + "\n " + doc[j:]
def escribe(ruta, texto):
try:
with open(ruta, encoding="utf-8") as f:
if f.read() == texto:
return
except FileNotFoundError:
pass
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(ruta), prefix=".tmp.")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(texto)
os.chmod(tmp, 0o644)
os.replace(tmp, ruta)
except BaseException:
os.unlink(tmp)
raise
def lista_comunicados(cs):
if not cs:
return " <p>Todavía no hay comunicados.</p>"
li = []
for c in cs:
t = fecha(c["ts"])
titulo, resto = partir(c["texto"])
ext = plano(resto)
if len(ext) > EXTRACTO:
ext = ext[:EXTRACTO].rsplit(" ", 1)[0] + "…"
li.append(
" <li>\n"
f' <time datetime="{t:%Y-%m-%d}">{t:%d.%m.%Y}</time>\n'
f' <h3><a href="/comunicados/{nombre(c)}">{html.escape(titulo)}</a></h3>\n'
+ (f" <p>{html.escape(ext)}</p>\n" if ext else "")
+ " </li>")
return ' <ol class="lista">\n' + "\n".join(li) + "\n </ol>"
def lista_simple(items, vacio, sub):
if not items:
return f" <p>{vacio}</p>"
li = []
for it in items:
s = sub(it)
li.append(f" <li><strong>{html.escape(it['titulo'])}</strong>"
+ (f" · {html.escape(s)}" if s else "") + "</li>")
return ' <ul class="lista">\n' + "\n".join(li) + "\n </ul>"
def pagina(plantilla, c):
t = fecha(c["ts"])
titulo, resto = partir(c["texto"])
art = (
"<article>\n"
f" <h1>{html.escape(titulo)}</h1>\n"
f' <p><time datetime="{t.isoformat(timespec="minutes")}">{t:%d.%m.%Y %H:%M}</time></p>\n'
f"{cuerpo(resto)}\n"
" </article>")
doc = cambia(plantilla, "articulo", " " + art)
return doc.replace("<title>Comunicado · Acampada 26S</title>",
f"<title>{html.escape(titulo)} · Acampada 26S</title>")
def main():
if len(sys.argv) != 2:
sys.exit("uso: node tools/extrae.js | genera.py SITE")
site = sys.argv[1]
datos = json.load(sys.stdin)
cs = sorted(datos.get("comunicados", []), key=lambda c: c["ts"], reverse=True)
com = sorted(datos.get("comisiones", []), key=lambda c: c["titulo"].lower())
deb = sorted(datos.get("debates", []), key=lambda c: c["ts"], reverse=True)
idx = os.path.join(site, "index.html")
with open(idx, encoding="utf-8") as f:
doc = f.read()
doc = cambia(doc, "comunicados", lista_comunicados(cs))
doc = cambia(doc, "comisiones", lista_simple(
com, "Las comisiones se crean como tribus dentro de OASIS.",
lambda c: plano(c["texto"])[:EXTRACTO]))
doc = cambia(doc, "debates", lista_simple(
deb, "Los debates se abren como foros dentro de OASIS.",
lambda d: d["categoria"]))
escribe(idx, doc)
dcom = os.path.join(site, "comunicados")
os.makedirs(dcom, exist_ok=True)
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "plantilla.html"),
encoding="utf-8") as f:
plantilla = f.read()
quedan = set()
for c in cs:
n = nombre(c)
quedan.add(n)
escribe(os.path.join(dcom, n), pagina(plantilla, c))
for n in os.listdir(dcom):
if NOMBRE.match(n) and n not in quedan:
os.unlink(os.path.join(dcom, n))
if __name__ == "__main__":
main()