From cb551e2a3ceeca64892bd098344a2d0c462c761c Mon Sep 17 00:00:00 2001 From: hacklab Date: Tue, 29 Sep 2026 10:25:28 +0200 Subject: [PATCH] Contenido desde OASIS y colectivos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 93 ++++++--- deploy/directo.service | 16 -- deploy/directo.timer | 9 - deploy/web26s.service | 18 ++ deploy/web26s.timer | 9 + site/comunicados/2026-09-27-primer-dia.html | 31 --- site/css/main.css | 8 + site/index.html | 77 ++++--- tools/directo.py | 89 -------- tools/extrae.js | 161 +++++++++++++++ tools/genera.py | 193 ++++++++++++++++++ .../plantilla.html | 12 +- tools/ultimos.js | 50 ----- 13 files changed, 505 insertions(+), 261 deletions(-) delete mode 100644 deploy/directo.service delete mode 100644 deploy/directo.timer create mode 100644 deploy/web26s.service create mode 100644 deploy/web26s.timer delete mode 100644 site/comunicados/2026-09-27-primer-dia.html delete mode 100755 tools/directo.py create mode 100755 tools/extrae.js create mode 100755 tools/genera.py rename site/comunicados/2026-09-26-convocatoria.html => tools/plantilla.html (69%) delete mode 100755 tools/ultimos.js diff --git a/README.md b/README.md index 80f62b1..7e4df7d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # acampada26s.net -Web de la acampada del 26S en Madrid. Comunicados, directo y cómo organizarse. +Web de la acampada del 26S en Madrid. Comunicados, directo, cómo organizarse +y dónde están los colectivos. HTML y CSS. Sin JavaScript en el navegador, sin cookies, sin analítica y sin peticiones a terceros. La tipografía va dentro del repo. @@ -14,69 +15,95 @@ cae o la tiran, el pub y lo publicado siguen en la red. ``` site/ lo que se sirve (copiar tal cual a /var/www/acampada26s.net) deploy/ nginx y timer de systemd -tools/ limpiar imágenes y regenerar el directo +tools/ generador desde OASIS y limpieza de imágenes ``` -## Publicar un comunicado +## Qué sale de dónde -1. Copiar `site/comunicados/2026-09-26-convocatoria.html` a - `site/comunicados/AAAA-MM-DD-titulo.html` y cambiar título, fecha y texto. -2. Añadir un `
  • ` arriba del todo en la lista `#comunicados` de `site/index.html`. -3. Fotos a `site/img/`, siempre limpias antes: +- **Directo**: enlace al HUB del pub (`/c?type=feed`). Lo sube la gente de + comunicación desde OASIS. No pasa por esta web. +- **Comunicados, comisiones y debates**: los genera `tools/` a partir de OASIS + cada dos minutos. Nadie edita HTML a mano. +- **Colectivos**: lista fija en `site/index.html`. Para añadir uno, un `
  • ` + más. Solo webs o cuentas comprobadas. - ``` - tools/limpia-img.sh site/img/*.jpg - ``` +## Publicar desde OASIS - El EXIF lleva GPS, modelo de móvil y hora. Además: caras tapadas salvo - permiso expreso. +Quien publica en la web: miembros de la tribu pública de la acampada **a los +que la cuenta de la acampada sigue**. Hacen falta las dos cosas. Unirse a una +tribu es un mensaje que cualquiera puede publicar sobre sí mismo (el código de +invitación solo lo comprueba la interfaz), así que la tribu sola no vale como +permiso. El follow lo firma la cuenta de la acampada y no se puede falsificar. -4. Toda imagen con `alt`, `width` y `height`. +- Dar permiso: invitar a la tribu y seguir desde la cuenta de la acampada. +- Quitarlo: dejar de seguir. Sus comunicados desaparecen en la siguiente pasada. + +Qué se publica: + +- **Comunicado**: un post con `#comunicado26s`. La primera línea es el título. + Las imágenes adjuntas salen en la página. Si se borra en OASIS, se borra aquí. +- **Comisión**: una subtribu pública de la tribu de la acampada, creada por + alguien con permiso. +- **Debate**: un foro público abierto por alguien con permiso. + +Las tribus privadas y lo que se escribe dentro de las tribus va cifrado; eso +no sale en la web ni debe salir. + +La tribu tiene que ser pública (no anónima) y con invitación estricta, y la +tiene que crear la cuenta de la acampada. + +## Imágenes + +Las de los comunicados las sirve el pub (`/c/blob/...`). Las que se suban a +mano a `site/img/`, siempre limpias antes: + +``` +tools/limpia-img.sh site/img/*.jpg +``` + +El EXIF lleva GPS, modelo de móvil y hora. Caras tapadas salvo permiso expreso. ## Despliegue -Requisitos: nginx, certbot. Para el directo: el pub OASIS en la misma máquina, -Node 22 y Python 3.9+. +Requisitos: nginx, certbot, el pub OASIS en la misma máquina, Node 22 y +Python 3.9+. ``` -rsync -a --delete site/ /var/www/acampada26s.net/ +rsync -a --exclude comunicados/ site/ /var/www/acampada26s.net/ cp deploy/nginx.conf /etc/nginx/sites-available/acampada26s.net cp deploy/hub.conf /etc/nginx/snippets/acampada26s-hub.conf ln -s ../sites-available/acampada26s.net /etc/nginx/sites-enabled/ nginx -t && systemctl reload nginx ``` -`rsync --delete` borra el bloque directo generado; el timer lo repone en un minuto. +Un `rsync` de `index.html` deja los bloques generados vacíos; el timer los +repone en dos minutos. ### Pub OASIS -El pub se arranca con `sh oasis.sh server` y el HUB web escucha en `:3000`. -Conviene que escuche solo en local (`--host=127.0.0.1`): nginx publica `/c` -en solo lectura (GET/HEAD) y le quita la IP del visitante. +`sh oasis.sh server`. El HUB web escucha en `:3000`; mejor solo en local +(`--host=127.0.0.1`). nginx publica `/c` en solo lectura y sin la IP de quien +visita. -Invite para la home: `sh oasis.sh invite 1000`, y pegarlo en `#organizate`. - -### Directo - -`tools/ultimos.js` lee los últimos posts de la identidad de la acampada por el -socket local del sbot. `tools/directo.py` los escribe en `index.html` entre -`` y ``, escapado y con escritura -atómica. Sigue siendo un fichero estático: aguanta mucha carga y no hace falta -JavaScript. +### Generador ``` cp -r . /opt/26s -cp deploy/directo.service deploy/directo.timer /etc/systemd/system/ -# editar FEED, OASIS_DIR y User en directo.service -systemctl enable --now directo.timer +cp deploy/web26s.service deploy/web26s.timer /etc/systemd/system/ +# rellenar CUENTA, TRIBU, OASIS_DIR y User en web26s.service +systemctl enable --now web26s.timer ``` A mano: ``` -OASIS_DIR=~/oasis node tools/ultimos.js @feed.ed25519 5 | python3 tools/directo.py site/index.html +OASIS_DIR=~/oasis CUENTA=@...ed25519 TRIBU=%...sha256 node tools/extrae.js \ + | python3 tools/genera.py site ``` +`extrae.js` solo lee del sbot por su socket local. `genera.py` escapa todo lo +que viene de SSB, solo deja enlaces `http(s)` y escribe de forma atómica. + ## Comprobaciones antes de subir ``` diff --git a/deploy/directo.service b/deploy/directo.service deleted file mode 100644 index 0623762..0000000 --- a/deploy/directo.service +++ /dev/null @@ -1,16 +0,0 @@ -[Unit] -Description=acampada26s: regenera el bloque DIRECTO de la home -After=oasis.service - -[Service] -Type=oneshot -User=oasis -Environment=OASIS_DIR=/home/oasis/oasis -Environment=FEED=@CAMBIAR.ed25519 -Environment=WEB=/var/www/acampada26s.net/index.html -# node tiene que estar en el PATH del servicio (o poner la ruta completa) -ExecStart=/bin/sh -c 'node /opt/26s/tools/ultimos.js "$FEED" 5 | python3 /opt/26s/tools/directo.py "$WEB"' -NoNewPrivileges=yes -ProtectSystem=strict -ReadWritePaths=/var/www/acampada26s.net -PrivateTmp=yes diff --git a/deploy/directo.timer b/deploy/directo.timer deleted file mode 100644 index 8efccb8..0000000 --- a/deploy/directo.timer +++ /dev/null @@ -1,9 +0,0 @@ -[Unit] -Description=acampada26s: DIRECTO cada minuto - -[Timer] -OnBootSec=1min -OnUnitActiveSec=1min - -[Install] -WantedBy=timers.target diff --git a/deploy/web26s.service b/deploy/web26s.service new file mode 100644 index 0000000..5e0c8a7 --- /dev/null +++ b/deploy/web26s.service @@ -0,0 +1,18 @@ +[Unit] +Description=acampada26s: comunicados, comisiones y debates desde OASIS +After=oasis.service + +[Service] +Type=oneshot +User=oasis +Environment=OASIS_DIR=/home/oasis/oasis +# cuenta de la acampada y clave (%...sha256) de su tribu pública +Environment=CUENTA= +Environment=TRIBU= +Environment=WEB=/var/www/acampada26s.net +# node tiene que estar en el PATH del servicio (o poner la ruta completa) +ExecStart=/bin/sh -c 'set -e; node /opt/26s/tools/extrae.js > /tmp/26s.json; python3 /opt/26s/tools/genera.py "$WEB" < /tmp/26s.json' +NoNewPrivileges=yes +ProtectSystem=strict +ReadWritePaths=/var/www/acampada26s.net +PrivateTmp=yes diff --git a/deploy/web26s.timer b/deploy/web26s.timer new file mode 100644 index 0000000..92488b5 --- /dev/null +++ b/deploy/web26s.timer @@ -0,0 +1,9 @@ +[Unit] +Description=acampada26s: actualizar cada 2 minutos + +[Timer] +OnBootSec=1min +OnUnitActiveSec=2min + +[Install] +WantedBy=timers.target diff --git a/site/comunicados/2026-09-27-primer-dia.html b/site/comunicados/2026-09-27-primer-dia.html deleted file mode 100644 index f54b081..0000000 --- a/site/comunicados/2026-09-27-primer-dia.html +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - -Primer día en la plaza · Acampada 26S - - - - - -
    -

    Acampada 26S

    -
    - -
    -
    -

    Primer día en la plaza

    -

    Borrador

    -

    Crónica de la primera jornada pendiente de aprobar en asamblea.

    -

    Las fotos se suben limpias de metadatos: tools/limpia-img.sh.

    -
    -
    - -
    - Textos CC BY-SA 4.0 · Código AGPL-3.0 · Fuente -
    - - diff --git a/site/css/main.css b/site/css/main.css index 9c7da65..b52a8a2 100644 --- a/site/css/main.css +++ b/site/css/main.css @@ -96,3 +96,11 @@ footer { margin: 64px 0 24px; border-top: var(--bd); padding-top: 12px; font-siz .cuadro:hover, .cuadro:focus-visible, .bt:hover, .bt:focus-visible { transform: none; } } + +/* colectivos */ +.colectivos { list-style: none; margin: 0 0 16px; padding: 0; columns: 2 280px; column-gap: 32px; } +.colectivos li { break-inside: avoid; padding: 6px 0; border-bottom: 1px solid var(--fg); } +.colectivos a { font-family: var(--tit); font-size: 22px; text-transform: uppercase; text-decoration: none; } +.colectivos a:hover, .colectivos a:focus-visible { color: var(--acc); } +.colectivos span { display: block; font-size: 14px; } +.lista strong { font-family: var(--tit); font-weight: 400; font-size: 22px; text-transform: uppercase; } diff --git a/site/index.html b/site/index.html index fe82833..8609ed6 100644 --- a/site/index.html +++ b/site/index.html @@ -23,44 +23,71 @@
    - - -

    Comunicados

    -
      -
    1. - Borrador -

      Primer día en la plaza

      -

      Resumen de la primera jornada y de las asambleas convocadas.

      -
    2. -
    3. - Borrador -

      Convocatoria 26S

      -

      Por qué acampamos, dónde y cómo sumarse.

      -
    4. -
    + +

    Todavía no hay comunicados.

    +

    Organízate

    -

    La acampada publica en su propio pub de OASIS, una red social libre sobre Secure Scuttlebutt. Sin servidores de empresa y sin cuentas que puedan cerrar desde fuera. Lo que se publica en la plaza aparece en Directo.

    -

    Entrar al pub

    +

    La acampada se organiza en OASIS, una red social libre sobre Secure Scuttlebutt. No hay empresa detrás ni cuentas que se puedan cerrar desde fuera. Al instalarla entras solo en La Plaza, el pub común, y desde ahí llegas a la acampada.

      -
    1. Instala OASIS en el ordenador o la APK en el móvil.
    2. -
    3. Abre OASIS, ve a Invites y pega este código:
    4. +
    5. Instala OASIS desde 0asis.net, en el móvil o en el ordenador.
    6. +
    7. Busca la tribu Acampada 26S.
    8. +
    9. Entra en la comisión que te toque y en los debates.
    -
    acampada26s.net:8008:@CAMBIAR.ed25519~CAMBIAR
    -

    A partir de ahí puedes leer, publicar y hablar con el resto de la acampada aunque esta web se caiga.

    +

    Directo es lo que la acampada va subiendo al pub. Se ve sin instalar nada.

    + +

    Comisiones

    + +

    Las comisiones se crean como tribus dentro de OASIS.

    + + +

    Debates

    + +

    Los debates se abren como foros dentro de OASIS.

    + +
    + +
    +

    Colectivos

    +

    Centros sociales y colectivos de Madrid. Aquí también hay información y asambleas.

    + +

    Vivienda y coordinadoras

    + +

    ¿Falta tu colectivo? Escribe a la comisión de comunicación en OASIS.

    diff --git a/tools/directo.py b/tools/directo.py deleted file mode 100755 index c0949c8..0000000 --- a/tools/directo.py +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env python3 -"""Reescribe el bloque DIRECTO de index.html con los últimos posts del pub. - -Lee de stdin el JSON de tools/ultimos.js y sustituye lo que haya entre - y . Escritura atómica: el -servidor web nunca ve un fichero a medias. - - node tools/ultimos.js @feed.ed25519 5 | python3 tools/directo.py /var/www/acampada26s.net/index.html -""" -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 - -INI = "" -FIN = "" -MAX = 400 # caracteres por post -TZ = ZoneInfo("Europe/Madrid") - - -def limpia(texto): - # fuera markdown de imágenes/enlaces y referencias ssb, queda el texto - texto = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", texto) - texto = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", texto) - texto = " ".join(texto.split()) - if len(texto) > MAX: - texto = texto[:MAX].rsplit(" ", 1)[0] + "…" - return texto - - -def bloque(posts): - li = [] - for p in posts: - t = datetime.fromtimestamp(p["ts"] / 1000, tz=TZ) - img = "" - if p.get("img"): - src = "/c/blob/" + quote(p["img"], safe="") - img = f'\n ' - li.append( - "
  • \n" - f' {img}\n' - f"

    {html.escape(limpia(p['text']))}

    \n" - "
  • " - ) - if not li: - return f"{INI}\n {FIN}" - return ( - f'{INI}\n
    \n

    Directo

    \n' - f'
      \n{chr(10).join(li)}\n
    \n' - f'

    Todo en OASIS

    \n
    \n {FIN}' - ) - - -def main(): - if len(sys.argv) != 2: - sys.exit("uso: ... | directo.py ruta/index.html") - ruta = sys.argv[1] - posts = json.load(sys.stdin) - if not isinstance(posts, list): - sys.exit("entrada no es una lista") - - with open(ruta, encoding="utf-8") as f: - doc = f.read() - i, j = doc.find(INI), doc.find(FIN) - if i < 0 or j < i: - sys.exit("no están las marcas directo:inicio / directo:fin") - nuevo = doc[:i] + bloque(posts) + doc[j + len(FIN):] - if nuevo == doc: - return - - d = os.path.dirname(os.path.abspath(ruta)) - fd, tmp = tempfile.mkstemp(dir=d, prefix=".index.") - try: - with os.fdopen(fd, "w", encoding="utf-8") as f: - f.write(nuevo) - os.chmod(tmp, 0o644) - os.replace(tmp, ruta) - except BaseException: - os.unlink(tmp) - raise - - -if __name__ == "__main__": - main() diff --git a/tools/extrae.js b/tools/extrae.js new file mode 100755 index 0000000..b3f4e0d --- /dev/null +++ b/tools/extrae.js @@ -0,0 +1,161 @@ +#!/usr/bin/env node +// Lee del sbot de OASIS lo que va a la web y lo saca en JSON para tools/genera.py. +// +// Quién publica: miembros de la tribu TRIBU a los que CUENTA sigue, más CUENTA. +// La tribu sola no basta: en SSB cualquiera puede publicar que se une a una tribu +// (la invitación solo la comprueba la interfaz de OASIS). El follow de CUENTA sí +// está firmado por CUENTA. +// +// comunicados posts con #comunicado26s de quien publica +// comisiones subtribus públicas de TRIBU creadas por quien publica +// debates foros públicos abiertos por quien publica +// +// Uso: OASIS_DIR=~/oasis CUENTA=@...ed25519 TRIBU=%...sha256 node tools/extrae.js +const path = require('path'); + +const dir = process.env.OASIS_DIR || path.join(process.env.HOME, 'oasis'); +const CUENTA = process.env.CUENTA; +const TRIBU = process.env.TRIBU; +const ETIQUETA = 'comunicado26s'; + +if (!CUENTA || !CUENTA.startsWith('@') || !TRIBU || !TRIBU.startsWith('%')) { + console.error('faltan CUENTA=@... y TRIBU=%...'); + process.exit(1); +} + +const ssbConfig = require(path.join(dir, 'src', 'server', 'ssb_config')); +const ssbClient = require(path.join(dir, 'src', 'server', 'node_modules', 'ssb-client')); + +// igual que STRUCTURAL_FIELDS en src/models/tribes_model.js +const ESTRUCTURA = ['title', 'description', 'image', 'location', 'tags', 'isAnonymous', + 'inviteMode', 'status', 'parentTribeId', 'mapUrl']; + +const todos = (src) => new Promise((res, rej) => { + const out = []; + const leer = () => src(null, (end, m) => { + if (end === true) return res(out); + if (end) return rej(end); + out.push(m); + leer(); + }); + leer(); +}); + +const lista = (x) => (Array.isArray(x) ? x : []); +const igual = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null); + +// Cadena de una tribu (create + updates por `replaces`) con las mismas reglas que +// buildTribeIndex: el autor raíz cambia lo que quiera; otro miembro solo puede +// entrar o salir él mismo. Devuelve el último estado válido. +function puntaTribu(raiz, msgs) { + const porPadre = new Map(); + for (const m of msgs) { + const r = m.value.content.replaces; + if (!r) continue; + if (!porPadre.has(r)) porPadre.set(r, []); + porPadre.get(r).push(m); + } + const autorRaiz = raiz.value.author; + let punta = raiz; + for (;;) { + const prev = punta.value.content; + const validos = lista(porPadre.get(punta.key)).filter((m) => { + const c = m.value.content; + const a = m.value.author; + if (a === autorRaiz) return true; + const antes = lista(prev.members); + const despues = lista(c.members); + if (!antes.includes(a) && !despues.includes(a)) return false; + const altas = despues.filter((x) => !antes.includes(x)); + const bajas = antes.filter((x) => !despues.includes(x)); + const delta = altas.length + bajas.length === 0 || + (altas.length === 1 && bajas.length === 0 && altas[0] === a) || + (bajas.length === 1 && altas.length === 0 && bajas[0] === a); + if (!delta) return false; + const inv = new Set(lista(prev.invites).map((i) => i && i.codeHash)); + if (lista(c.invites).some((i) => i && i.codeHash && !inv.has(i.codeHash))) return false; + return ESTRUCTURA.every((f) => igual(prev[f], c[f])); + }); + if (!validos.length) return punta; + validos.sort((x, y) => + (y.value.author === autorRaiz) - (x.value.author === autorRaiz) || + x.value.timestamp - y.value.timestamp || + (x.key < y.key ? -1 : 1)); + punta = validos[0]; + } +} + +function imagenes(texto) { + const re = /!\[[^\]]*\]\((&[A-Za-z0-9+/=]+\.sha256)\)/g; + return [...texto.matchAll(re)].map((m) => m[1]); +} + +const pub = (ssbConfig.keys.public || '').replace('.ed25519', ''); +const remote = `unix:${path.join(ssbConfig.path, 'socket')}~noauth:${pub}`; + +ssbClient(ssbConfig.keys, { remote, caps: ssbConfig.caps }).then(async (ssb) => { + const tipo = (type) => todos(ssb.messagesByType({ type })); + const [contactos, tribus, posts, feeds, foros, lapidas] = await Promise.all( + ['contact', 'tribe', 'post', 'feed', 'forum', 'tombstone'].map(tipo)); + + // a quién sigue CUENTA (el último contact gana) + const sigue = new Map(); + for (const m of contactos) { + const c = m.value.content; + if (m.value.author !== CUENTA || typeof c.contact !== 'string') continue; + if (typeof c.following === 'boolean') sigue.set(c.contact, c.following && !c.blocking); + else if (c.blocking) sigue.set(c.contact, false); + } + + const raiz = tribus.find((m) => m.key === TRIBU); + if (!raiz || raiz.value.author !== CUENTA || raiz.value.content.isAnonymous !== false) { + throw new Error('TRIBU no existe, no es pública o no la creó CUENTA'); + } + const deTribu = (r) => tribus.filter((m) => m.value.content.replaces).concat([r]); + const miembros = lista(puntaTribu(raiz, deTribu(raiz)).value.content.members); + const editores = new Set([CUENTA, ...miembros.filter((id) => sigue.get(id) === true)]); + + // borrado: tombstone del propio autor + const borrado = new Set(lapidas + .filter((m) => typeof m.value.content.target === 'string') + .map((m) => `${m.value.author} ${m.value.content.target}`)); + const vivo = (m) => !borrado.has(`${m.value.author} ${m.key}`); + const deEditor = (m) => editores.has(m.value.author) && vivo(m); + + const lleva = (c) => (typeof c.text === 'string' && + c.text.toLowerCase().includes(`#${ETIQUETA}`)) || + lista(c.tags).some((t) => String(t).toLowerCase().replace(/^#/, '') === ETIQUETA); + + const comunicados = posts.concat(feeds) + .filter((m) => deEditor(m) && !m.value.content.root && lleva(m.value.content)) + .map((m) => ({ + key: m.key, + autor: m.value.author, + ts: m.value.timestamp, + texto: m.value.content.text, + imgs: imagenes(m.value.content.text), + })); + + const comisiones = tribus + .filter((m) => !m.value.content.replaces && m.value.content.parentTribeId === TRIBU && + m.value.content.isAnonymous === false && deEditor(m)) + .map((r) => { + const c = puntaTribu(r, deTribu(r)).value.content; + return { key: r.key, ts: r.value.timestamp, titulo: c.title || '', texto: c.description || '' }; + }); + + const debates = foros + .filter((m) => typeof m.value.content === 'object' && !m.value.content.root && + !m.value.content.isPrivate && deEditor(m)) + .map((m) => ({ + key: m.key, + ts: m.value.timestamp, + titulo: m.value.content.title || '', + categoria: m.value.content.category || '', + })); + + ssb.close(() => console.log(JSON.stringify({ comunicados, comisiones, debates }))); +}).catch((e) => { + console.error(e.message); + process.exit(1); +}); diff --git a/tools/genera.py b/tools/genera.py new file mode 100755 index 0000000..beb9aee --- /dev/null +++ b/tools/genera.py @@ -0,0 +1,193 @@ +#!/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 y + 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'') + else: + trozos.append(f'' + f"{html.escape(m.group(2))}") + pos = m.end() + trozos.append(html.escape(parrafo[pos:])) + p = "".join(trozos).strip().replace("\n", "
    \n") + out.append(f"

    {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"", f"" + 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 "

    Todavía no hay comunicados.

    " + 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( + "
  • \n" + f' \n' + f'

    {html.escape(titulo)}

    \n' + + (f"

    {html.escape(ext)}

    \n" if ext else "") + + "
  • ") + return '
      \n' + "\n".join(li) + "\n
    " + + +def lista_simple(items, vacio, sub): + if not items: + return f"

    {vacio}

    " + li = [] + for it in items: + s = sub(it) + li.append(f"
  • {html.escape(it['titulo'])}" + + (f" · {html.escape(s)}" if s else "") + "
  • ") + return ' " + + +def pagina(plantilla, c): + t = fecha(c["ts"]) + titulo, resto = partir(c["texto"]) + art = ( + "
    \n" + f"

    {html.escape(titulo)}

    \n" + f'

    \n' + f"{cuerpo(resto)}\n" + "
    ") + doc = cambia(plantilla, "articulo", " " + art) + return doc.replace("Comunicado · Acampada 26S", + f"{html.escape(titulo)} · Acampada 26S") + + +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() diff --git a/site/comunicados/2026-09-26-convocatoria.html b/tools/plantilla.html similarity index 69% rename from site/comunicados/2026-09-26-convocatoria.html rename to tools/plantilla.html index 9263acf..13b7cda 100644 --- a/site/comunicados/2026-09-26-convocatoria.html +++ b/tools/plantilla.html @@ -5,23 +5,19 @@ -Convocatoria 26S · Acampada 26S +Comunicado · Acampada 26S
    -

    Acampada 26S

    + Acampada 26S
    -
    -

    Convocatoria 26S

    -

    Borrador

    -

    Texto de la convocatoria pendiente de aprobar en asamblea.

    -

    Lugar, hora y motivos van aquí.

    -
    + +