From 210d11aff27338f27012f6cd47fc5794c9703f3c Mon Sep 17 00:00:00 2001 From: primoco <58369875+primoco@users.noreply.github.com> Date: Sat, 7 Nov 2026 07:54:56 +0000 Subject: [PATCH] 0.31.6: guida per template su misura, webhook con translation_of e indirizzo dell'articolo dalla risposta --- CHANGELOG.md | 22 ++ Cargo.lock | 2 +- Cargo.toml | 2 +- editor-mode/docs/sito-pronto-per-redazia.md | 4 + editor-mode/docs/template-su-misura.md | 258 ++++++++++++++++++++ editor-mode/pyproject.toml | 2 +- editor-mode/src/editor/publishing.py | 27 +- editor-mode/tests/test_publishing.py | 8 +- editor-mode/uv.lock | 2 +- 9 files changed, 316 insertions(+), 11 deletions(-) create mode 100644 editor-mode/docs/template-su-misura.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bcc657..e701a2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## 0.31.6 + +### Added + +- **Guida per chi costruisce il proprio template** + (`editor-mode/docs/template-su-misura.md`, Francesco, 7 novembre). Copre + i tre canali (GitHub, webhook, feed) con tutto quello che serve: + - struttura del repository, campi del front matter e nomi riconosciuti; + - cosa deve fare il template; + - come Redazia pubblica, modifica e ritira; + - firma del webhook con esempi in Node, PHP e Python, risposta attesa; + - verifiche prima di andare in produzione. + +### Changed + +- **Webhook più completo.** La pubblicazione e la modifica mandano anche + `translation_of`, cioè lo slug dell'originale, perché il sito possa + collegare le lingue. La pubblicazione manda anche `date`. Se il sito + risponde con `{"id": ..., "url": "https://..."}`, Redazia ricorda + l'indirizzo vero dell'articolo e lo mostra nella bozza e nelle + pubblicazioni. + ## 0.31.5 ### Changed diff --git a/Cargo.lock b/Cargo.lock index acafc54..006b088 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -704,7 +704,7 @@ dependencies = [ [[package]] name = "eullm-agent" -version = "0.31.5" +version = "0.31.6" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 082f2a2..acd17e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "eullm-agent" -version = "0.31.5" +version = "0.31.6" edition = "2021" license = "AGPL-3.0-or-later" description = "EULLM Agent — autonomous ReAct agent with multi-provider LLM support" diff --git a/editor-mode/docs/sito-pronto-per-redazia.md b/editor-mode/docs/sito-pronto-per-redazia.md index 1cab5ed..dfd52d5 100644 --- a/editor-mode/docs/sito-pronto-per-redazia.md +++ b/editor-mode/docs/sito-pronto-per-redazia.md @@ -188,6 +188,10 @@ l'articolo va tolto dal sito. Rispondi con un codice 2xx quando l'articolo è salvato; con un errore la pubblicazione risulta fallita in Pubblicazioni, con il motivo. +Chi costruisce un proprio template o endpoint trova il contratto completo +(campi, firma con esempi in Node, PHP e Python, risposta, modifiche e +ritiri) in [Un template su misura per Redazia](template-su-misura.md). + ## Nessuna delle tre: un sito nuovo accanto al tuo Se la tua piattaforma non permette di scrivere da fuori (Squarespace, molti diff --git a/editor-mode/docs/template-su-misura.md b/editor-mode/docs/template-su-misura.md new file mode 100644 index 0000000..895f10e --- /dev/null +++ b/editor-mode/docs/template-su-misura.md @@ -0,0 +1,258 @@ +# Un template su misura per Redazia + +Questa guida è per chi costruisce il proprio sito o template e vuole che +Redazia ci pubblichi senza adattamenti. È scritta per gli sviluppatori. +Per WordPress e per i modelli Redazia basta [la guida per i +clienti](sito-pronto-per-redazia.md). + +Redazia non chiede API particolari al sito: usa uno di tre canali standard. +Il template deve solo rispettare il contratto del canale scelto. + +| Canale | Come arriva l'articolo | Adatto a | +|---|---|---| +| **GitHub** | un commit con un file Markdown nel repository | siti statici: Astro, Hugo, Eleventy, Next, Jekyll, Gatsby | +| **Webhook** | una `POST` JSON firmata verso un tuo endpoint | CMS fatti su misura, app, headless CMS con una funzione davanti | +| **Feed** | il sito legge un JSON Feed o un RSS di Redazia | import periodici, Zapier, Make, n8n | + +GitHub e webhook ricevono anche le **modifiche** e i **ritiri** decisi dal +direttore dopo la pubblicazione. Il feed contiene solo gli articoli +approvati. + +--- + +## 1. GitHub: articoli come file Markdown + +### Struttura del repository + +``` +src/content/articles/it/titolo-articolo.md ← una cartella per lingua +src/content/articles/en/article-title.md +public/images/titolo-articolo.png ← copertine +public/llms.txt ← facoltativo, Redazia aggiunge una riga +public/sitemap.xml ← solo se è un file; se la generi alla build non serve +``` + +- **Cartella degli articoli:** una per lingua, indicata nella destinazione + (`path`). Se il sito ha una lingua sola basta una cartella. +- **Copertine:** in `image_path` se lo indichi, altrimenti dove le tengono + gli articoli già presenti, altrimenti in `public/images`. Il nome è + `{slug}.png` o `{slug}.jpg`. Se ogni articolo ha la sua cartella con + l'immagine dentro, Redazia fa lo stesso (`{slug}/index.md` e `cover.png`). +- **Articoli già scritti:** se ce ne sono, Redazia legge gli ultimi otto e + copia il loro formato: nomi e ordine dei campi, formato della data, `.md` + o `.mdx`, articolo in un file o in una cartella, valori che sono uguali in + tutti (autore, layout). Se la cartella è vuota usa il formato standard. + +### Front matter standard + +```yaml +--- +title: "Titolo dell'articolo" +description: "Descrizione per Google, al massimo 160 caratteri" +slug: "titolo-dell-articolo" +date: 2026-11-07T08:00:00+00:00 +lang: "it" +section: "Tecnologia" +keywords: ["parola chiave"] +image: "/images/titolo-dell-articolo.png" +image_alt: "Cosa mostra l'immagine" +translation_of: "slug-dell-originale" # solo nelle traduzioni +--- +``` + +Se il tuo template usa altri nomi, Redazia li riconosce e scrive quelli: + +| Ruolo | Nomi riconosciuti | +|---|---| +| titolo | `title` | +| descrizione | `description`, `excerpt`, `summary`, `meta_description`, `subtitle` | +| data | `pubDate`, `date`, `publishDate`, `published_at`, `created`… | +| immagine | `heroImage`, `image`, `cover`, `coverImage`, `thumbnail`, `featuredImage`, `ogImage`, `banner` (anche annidata, es. `image: {src, alt}`) | +| testo alternativo | `heroImageAlt`, `imageAlt`, `image_alt`, `coverAlt`, `alt` | +| parole chiave | `tags`, `keywords` | +| lingua | `lang`, `language`, `locale` | +| rubrica | `section`, `category`, `rubrica` | + +Un campo `draft` viene scritto `false`, un campo `published` viene scritto +`true`. + +### Cosa deve fare il template + +1. **Leggere il Markdown così com'è**, con `##` per i sottotitoli, link, + elenchi e le note `[^1]` (GitHub Flavored Markdown; in Astro le note + funzionano di serie, in Hugo con Goldmark). Il primo titolo `#` non c'è: + il titolo sta nel front matter. +2. **Non chiedere campi che Redazia non scrive.** Con Astro Content + Collections dichiara facoltativi i campi che non sono nella tabella qui + sopra (per esempio `author`, se non è uguale in tutti gli articoli) e + aggiungi `translation_of` come facoltativo. Non usare `.strict()`: un + campo in più o uno obbligatorio mancante rompono la build, e la pull + request resta ferma. +3. **Dare a ogni articolo un indirizzo stabile con lo slug**, per esempio + `/articoli/{slug}` e `/en/articles/{slug}`. Lo stesso schema va nella + destinazione (`url`), così Redazia conosce il link vero. +4. **Collegare le lingue con `translation_of`**: la traduzione porta lo + slug dell'originale. Usalo per il selettore di lingua e per i tag + `hreflang`. +5. **Mostrare la rubrica** (`section`) e avere una pagina per rubrica. + I nomi delle rubriche devono essere uguali a quelli scritti in Redazia, + in Sito e profilo › Redazione automatica. +6. **Ricostruire il sito a ogni commit** sul ramo principale (Vercel, + Netlify e Cloudflare Pages lo fanno di serie). +7. **Generare alla build** sitemap, RSS e possibilmente `sitemap-news.xml` + e dati strutturati `NewsArticle`. +8. **Con la redazione automatica**, mostrare in ogni articolo che è scritto + dall'intelligenza artificiale (AI Act, art. 50). Redazia mette comunque + l'etichetta in fondo al testo. + +### Come Redazia pubblica, modifica e ritira + +- **Pubblica:** un solo commit con l'articolo, la copertina, la riga in + `llms.txt` e la sitemap se è un file. Un commit, un deploy. Il commit va + su un ramo con una pull request, che Redazia unisce solo quando i + controlli del repository sono verdi (di solito l'anteprima di Vercel o + Netlify), aspettando fino a 15 minuti. Senza controlli la unisce subito. + Un file con lo stesso nome e un contenuto diverso non viene mai + sovrascritto: la pubblicazione fallisce e lo dice. +- **Modifica:** lo stesso file viene riscritto, nello stesso modo. +- **Ritira:** il file viene cancellato con la sua copertina, e la riga + esce da `llms.txt` e dalla sitemap. + +### Token + +Token GitHub "fine-grained", limitato al solo repository del sito, con i +permessi **Contents: Read and write**, **Pull requests: Read and write** e +**Commit statuses: Read**. + +--- + +## 2. Webhook: un endpoint che riceve JSON + +Redazia manda una `POST` con `Content-Type: application/json` all'indirizzo +della destinazione. L'indirizzo deve essere pubblico e in HTTPS, senza +credenziali dentro; i redirect non vengono seguiti. + +### Pubblicazione + +```json +{ + "event": "publication", + "mode": "publish", + "publication_id": 42, + "url": null, + "title": "Titolo", + "subtitle": "Sommario", + "markdown": "# Titolo\n\nTesto…", + "html": "

Titolo

Testo…

", + "language": "it", + "slug": "titolo", + "section": "Tecnologia", + "seo_title": "Titolo per Google", + "meta_description": "…", + "keyphrase": "…", + "jsonld": [{"@type": "NewsArticle", "...": "..."}], + "translation_of": null, + "date": "2026-11-07T08:00:00+00:00", + "image": {"url": "https://app.redazia.com/media/…", "alt": "…", "type": "image/png"} +} +``` + +- `mode`: `publish` (pubblica) o `draft` (salva come bozza). +- `translation_of`: nelle traduzioni, lo slug dell'originale. +- `image`: scarica l'immagine e salvala da te. Il link funziona, ma è + meglio non dipendere da Redazia per servirla. +- `markdown` e `html` contengono anche il titolo come `#`/`

`: se il tuo + template mostra il titolo dal suo campo, togli la prima riga. + +### Modifica e ritiro + +- **`"event": "update"`**: lo stesso corpo, con lo **stesso `slug`**. Va + riscritto lo stesso articolo. In `image` c'è la copertina attuale: un + link diverso vuol dire una copertina nuova, `null` che è stata tolta. +- **`"event": "withdraw"`**: arrivano `publication_id`, `slug` e `url`. + L'articolo va tolto dal sito. + +### Firma + +Ogni richiesta porta `X-Editor-Signature: sha256=`, +calcolato con il segreto della destinazione. Verificala sempre sul **corpo +grezzo**, prima di leggere il JSON. + +Node (Express): + +```js +import crypto from 'node:crypto'; +app.post('/redazia', express.raw({ type: 'application/json' }), (req, res) => { + const want = 'sha256=' + crypto.createHmac('sha256', process.env.REDAZIA_SECRET).update(req.body).digest('hex'); + const got = req.get('X-Editor-Signature') || ''; + if (got.length !== want.length || !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(want))) return res.sendStatus(401); + const a = JSON.parse(req.body); + // salva, aggiorna o togli l'articolo a.slug + res.json({ id: a.slug, url: `https://tuosito.it/news/${a.slug}` }); +}); +``` + +PHP: + +```php +$raw = file_get_contents('php://input'); +$want = 'sha256=' . hash_hmac('sha256', $raw, getenv('REDAZIA_SECRET')); +if (!hash_equals($want, $_SERVER['HTTP_X_EDITOR_SIGNATURE'] ?? '')) { http_response_code(401); exit; } +$a = json_decode($raw, true); +// salva, aggiorna o togli l'articolo $a['slug'] +header('Content-Type: application/json'); +echo json_encode(['id' => $a['slug'], 'url' => "https://tuosito.it/news/{$a['slug']}"]); +``` + +Python: + +```python +import hashlib, hmac, os +def valid(raw: bytes, header: str) -> bool: + want = "sha256=" + hmac.new(os.environ["REDAZIA_SECRET"].encode(), raw, hashlib.sha256).hexdigest() + return hmac.compare_digest(want, header or "") +``` + +### Risposta + +- Rispondi **2xx solo quando l'articolo è salvato**. Con un altro codice la + pubblicazione risulta fallita in Pubblicazioni, con il motivo, e il + direttore può riprovare. +- Se rispondi con `{"id": "...", "url": "https://..."}`, Redazia ricorda + l'indirizzo vero dell'articolo e lo mostra nella bozza e nelle + pubblicazioni. Senza, l'articolo risulta pubblicato ma senza link. +- Rendi l'endpoint **idempotente sullo `slug`**: se arriva due volte lo + stesso articolo, aggiornalo invece di duplicarlo. +- Rispondi entro 30 secondi. Se il lavoro è lungo (immagini, + ricostruzione del sito), salva e rispondi subito, poi lavora in coda. + +--- + +## 3. Feed: il sito legge da Redazia + +In Sito e profilo › "Come ricevi gli articoli" Redazia crea un feed per +sito con un indirizzo segreto: + +- `https://app.redazia.com/feeds/.json` (JSON Feed 1.1) +- `https://app.redazia.com/feeds/.rss` (RSS 2.0) + +Ogni voce del JSON Feed ha `id`, `title`, `summary`, `content_html`, +`image`, `date_published`, `language` e, sotto `_redazia`, `slug`, +`subtitle`, `markdown`, `seo_title`, `meta_description`, `keyphrase`, +`image_alt`, `sources`, `jsonld` e `translation_of`. Usa `id` per non +importare due volte lo stesso articolo. + +Il feed contiene gli ultimi 50 articoli approvati e non avvisa di +modifiche e ritiri: se servono, usa il webhook. + +--- + +## 4. Prima di andare in produzione + +1. Crea la destinazione in Redazia e pubblica un articolo a mano. +2. Controlla sul sito titolo, sommario, copertina, rubrica, data, note + delle fonti e link. +3. Pubblica la traduzione e controlla che il selettore di lingua le colleghi. +4. Modifica l'articolo da Redazia ("Aggiorna sul sito") e poi ritiralo: + devono funzionare tutti e due. +5. Solo dopo accendi la redazione automatica. diff --git a/editor-mode/pyproject.toml b/editor-mode/pyproject.toml index 2000ea9..1c29e69 100644 --- a/editor-mode/pyproject.toml +++ b/editor-mode/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "eullm-editor-mode" -version = "0.31.5" +version = "0.31.6" description = "Editor Mode for EuLLM Agent: domain-first editorial intelligence (site analysis, source discovery, trends, proposals, drafts) on top of the Core API" readme = "README.md" license = "AGPL-3.0-or-later" diff --git a/editor-mode/src/editor/publishing.py b/editor-mode/src/editor/publishing.py index e623c16..8cefbfe 100644 --- a/editor-mode/src/editor/publishing.py +++ b/editor-mode/src/editor/publishing.py @@ -585,13 +585,29 @@ def _wordpress_withdraw(self, target, pub, payload, secret, image=None): r.raise_for_status() return None + @staticmethod + def _webhook_answer(r) -> tuple[str | None, str | None]: + """What the site answered, when it says it: {"id": ..., "url": "https://..."}, the + article's own id and address, so the dashboard links the article on the site.""" + try: + body = r.json() + except ValueError: + return None, None + if not isinstance(body, dict): + return None, None + url = body.get("url") + url = url if isinstance(url, str) and url.startswith(("https://", "http://")) and len(url) <= 2000 else None + ext = body.get("id") + return (str(ext)[:200] if isinstance(ext, (str, int)) and str(ext) else None), url + def _webhook_event(self, target, pub, payload, secret, event: str, image=None): url = target.config["url"] check_public_url(url, self.resolver) data = {"event": event, "mode": pub.mode, "publication_id": pub.id, "url": pub.external_url} if event == "update": data |= {k: payload.get(k) for k in ("title", "subtitle", "markdown", "html", "language", "slug", "section", - "seo_title", "meta_description", "keyphrase", "jsonld")} + "seo_title", "meta_description", "keyphrase", "jsonld", + "translation_of")} # The cover as on publishing: the receiver replaces its own when the link changes. data["image"] = ({"url": f"{self.public_url}/media/{image.token}", "alt": image.alt, "type": image.mime} if image is not None and self.public_url else None) @@ -602,7 +618,7 @@ def _webhook_event(self, target, pub, payload, secret, event: str, image=None): r = self.http.post(url, content=raw, headers={"Content-Type": "application/json", "X-Editor-Signature": f"sha256={sig}"}) _ensure_no_redirect(r, url, self.resolver) r.raise_for_status() - return None + return self._webhook_answer(r)[1] if event == "update" else None def _webhook_update(self, target, pub, payload, secret, image=None): return self._webhook_event(target, pub, payload, secret, "update", image) @@ -796,14 +812,15 @@ def _webhook(self, target, pub, cms_url, secret, image=None): cover = {"url": f"{self.public_url}/media/{image.token}", "alt": image.alt, "type": image.mime} data = json.dumps({"event": "publication", "mode": pub.mode, "publication_id": pub.id, "url": cms_url, **{k: pub.payload[k] for k in ("title", "subtitle", "markdown", "html", "language")}, - **{k: pub.payload.get(k) for k in ("slug", "section", "seo_title", "meta_description", "keyphrase", "jsonld")}, - "image": cover}, + **{k: pub.payload.get(k) for k in ("slug", "section", "seo_title", "meta_description", "keyphrase", + "jsonld", "translation_of")}, + "date": datetime.now(UTC).isoformat(), "image": cover}, ensure_ascii=False).encode() sig = hmac.new(_need(secret, target).encode(), data, hashlib.sha256).hexdigest() r = self.http.post(url, content=data, headers={"Content-Type": "application/json", "X-Editor-Signature": f"sha256={sig}"}) _ensure_no_redirect(r, url, self.resolver) r.raise_for_status() - return None, None + return self._webhook_answer(r) def _telegram_channel(self, target, pub, cms_url, secret, image=None): import html as h diff --git a/editor-mode/tests/test_publishing.py b/editor-mode/tests/test_publishing.py index c48725b..1bac177 100644 --- a/editor-mode/tests/test_publishing.py +++ b/editor-mode/tests/test_publishing.py @@ -121,15 +121,19 @@ def test_webhook_is_signed_and_telegram_token_is_not_logged(db, tenant, monkeypa tg = pub.add_target(db, tenant, site_id, "telegram_channel", "canale", {"chat_id": "@canale"}, "TG_TOKEN") publisher = pub.Publisher(resolver=public_resolver) with respx.mock() as router: - route = router.post("https://hooks.example/in").mock(return_value=httpx.Response(200)) + # The site may answer with its own id and the article's address: the dashboard links it. + route = router.post("https://hooks.example/in").mock( + return_value=httpx.Response(200, json={"id": 9, "url": "https://sito.example/news/bari-in-fibra"})) router.post("https://api.telegram.org/bot123:ABC/sendMessage").mock(return_value=httpx.Response(500)) p1 = pub.request(db, tenant, did, hook, "publish", "redazione") p2 = pub.request(db, tenant, did, tg, "publish", "redazione") for p in (p1, p2): pub.decide(db, tenant, p, True, "francesco") - assert publisher.run(db, tenant, p1)["status"] == "published" + assert publisher.run(db, tenant, p1) == {"status": "published", "url": "https://sito.example/news/bari-in-fibra"} failed = publisher.run(db, tenant, p2) req = route.calls[0].request + sent = json.loads(req.content) + assert sent["event"] == "publication" and "translation_of" in sent and sent["date"] expected = hmac.new(b"s3cret", req.content, hashlib.sha256).hexdigest() assert req.headers["x-editor-signature"] == f"sha256={expected}" assert failed["status"] == "failed" and "123:ABC" not in failed["error"] diff --git a/editor-mode/uv.lock b/editor-mode/uv.lock index 10433b1..a9b3b8f 100644 --- a/editor-mode/uv.lock +++ b/editor-mode/uv.lock @@ -224,7 +224,7 @@ wheels = [ [[package]] name = "eullm-editor-mode" -version = "0.31.5" +version = "0.31.6" source = { editable = "." } dependencies = [ { name = "alembic" },