commit 3bf557f92d9fa3fac4e8e72feffb2b2fa83cd3fa Author: Vale Date: Thu Oct 15 14:53:41 2026 +0000 Digest OSS Hacker News: manuale completo + workflow n8n + script audio (riproducibile, MIT) diff --git a/README.md b/README.md new file mode 100644 index 0000000..11c59c8 --- /dev/null +++ b/README.md @@ -0,0 +1,198 @@ +# 🦊 Digest OSS Hacker News β€” vocalino e testo, ogni 12 ore + +Un sistema **completamente gratuito e riproducibile** che: + +1. raccoglie **tutti** i progetti open source apparsi su Hacker News nelle ultime 12 ore +2. **legge il README vero** di ogni progetto prima di descriverlo (niente descrizioni dal titolo!) +3. scrive un digest testuale **in italiano** e lo invia su un canale/bot Telegram +4. genera un **vocalino "raccontato"** (stile podcast, non elenco) con TUTTI i progetti, uno per uno, e lo consegna come messaggio vocale + +Dove gira: **n8n** (testo + checkpoint) e **Hermes Agent** (audio) su un VPS con Docker. +Nessun servizio a pagamento: le uniche chiavi necessarie sono gratuite (Google AI Studio, un bot Telegram, API n8n). + +> **PerchΓ© "tutti i progetti"?** PerchΓ© il filtro Γ¨: Show HN + qualsiasi storia con link a GitHub/GitLab/Codeberg + news open source con β‰₯3 punti. Non si scelgono i "migliori": si perdono zero news, e la selezione la fai tu mentre ascolti. + +--- + +## Architettura in un disegno + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” ogni 12h β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Hacker News │────────────▢│ n8n workflow "Digest OSS Hacker News" β”‚ +β”‚ (Algolia API)β”‚ β”‚ 1. Legge il checkpoint (data table) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ 2. Scarica Show HN + storie dal last run β”‚ + β”‚ 3. Filtra OSS (repo link, keyword) β”‚ + β”‚ 4. SCARICA IL README di ogni progetto β”‚ + β”‚ 5. Gemini Flash: digest italiano β”‚ + β”‚ (descrizioni basate sul README) β”‚ + β”‚ 6. Invia su Telegram (bot) β”‚ + β”‚ 7. Checkpoint avanti (append-only) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ 20 min dopo + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Hermes cron "Digest OSS audio raccontato"β”‚ + β”‚ 1. Prende il digest dall'esecuzione n8n β”‚ + β”‚ 2. Scrive la NARRAZIONE (stile podcast) β”‚ + β”‚ 3. Gemini TTS β†’ MP3 (segmenti + cache) β”‚ + β”‚ 4. Consegna il vocalino in chat β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Principi chiave** (quelli che rendono il sistema affidabile): + +- **Checkpoint anti-buchi**: un timestamp dell'ultimo momento elaborato vive in una data table di n8n in modalitΓ  *append-only*. Se tutto si rompe (VPS giΓΉ, API sovraccarica), il checkpoint non avanza e alla ripresa il digest riprende da dove era. Zero news perse, verificato in produzione. +- **Verifica README**: ogni descrizione Γ¨ basata sul testo reale del repository. Se il README non Γ¨ raggiungibile, la voce Γ¨ marcata `[non verificato]`. +- **Ridondanza Gemini**: il modello principale sovraccarico? La chiamata passa automaticamente a `flash-lite`. Quota TTS esaurita? Il generatore audio fa cascata tra 4 modelli TTS. +- **Cache dei segmenti audio**: ogni pezzo di vocalino generato viene salvato su disco. Riavvii e crash non fanno rifare nulla. + +--- + +## Prerequisiti + +| Cosa | Dove | Costo | +|---|---|---| +| Server con Docker + n8n | il tuo VPS | giΓ  tuo | +| API key Gemini | [aistudio.google.com](https://aistudio.google.com) β†’ "Get API key" | gratis (1.500 req/giorno sui modelli Flash) | +| Bot Telegram | [@BotFather](https://t.me/BotFather) β†’ `/newbot` | gratis | +| Chat ID tuo | scrivi al tuo bot e leggi `getUpdates` (vedi sotto) | gratis | +| API key n8n | n8n β†’ Settings β†’ n8n API β†’ crea chiave | gratis | +| Hermes Agent | [hermes-agent](https://github.com/NousResearch/hermes-agent) sullo stesso VPS | open source | + +Per scoprire il tuo chat ID Telegram: scrivi un messaggio al tuo bot, poi + +```bash +curl "https://api.telegram.org/bot/getUpdates" | grep -o '"id":[0-9]*' | head -1 +``` + +--- + +## Installazione (30 minuti circa) + +### 1. La data table del checkpoint in n8n + +Con la tua API key n8n: + +```bash +curl -X POST https://TUO-N8N/api/v1/data-tables \ + -H "X-N8N-API-KEY: CHIAVE" -H "Content-Type: application/json" \ + -d '{"name": "hn_checkpoint", "columns": [{"name": "sinceEpoch", "type": "number"}]}' +``` + +Segna l'`id` dalla risposta. Poi inserisci la prima riga (parti da "12 ore fa"): + +```bash +SINCE=$(( $(date +%s) - 43200 )) +curl -X POST https://TUO-N8N/api/v1/data-tables//rows \ + -H "X-N8N-API-KEY: CHIAVE" -H "Content-Type: application/json" \ + -d "{\"data\": [{\"sinceEpoch\": $SINCE}]}" +``` + +### 2. Il workflow + +1. In n8n: **Import from File** β†’ `n8n/workflow-digest-oss.json` +2. Apri il nodo **Credenziali** (Set) e compila: + - `gemini_key` β€” la tua chiave Google AI Studio + - `telegram_token` β€” token del bot + - `telegram_chat_id` β€” il tuo chat ID + - `n8n_key` β€” la stessa chiave API n8n (il workflow legge la sua data table via REST API; il nodo DataTable nativo Γ¨ rotto su n8n con task runner β€” vedi Pitfall #5) +3. Apri i nodi **Leggi checkpoint** e **Aggiorna checkpoint** e sostituisci l'ID `IL_TUO_TABLE_ID` con il TABLE_ID del punto 1 +4. Attiva il workflow. Per il test: metti un cron one-shot a ~10 minuti nel futuro (in ora **locale** del tuo n8n β€” controlla il fuso!) e attendi l'esecuzione + +**Test riuscito quando**: esecuzione verde in n8n β†’ messaggi sul bot Telegram β†’ nuova riga nella data table. + +### 3. Le chiavi per la parte audio (Hermes) + +Crea `hn-oss-digest/creds.json` (fuori dal git!) nel file system di Hermes, e metti lΓ¬ anche gli ID: + +```json +{ + "gemini_key": "AIza...", + "telegram_token": "12345:ABC...", + "n8n_key": "eyJhbG...", + "telegram_chat_id": "123456789", + "n8n_base": "https://TUO-N8N", + "n8n_table_id": "ID_TABELLA", + "n8n_workflow_id": "ID_DEL_WORKFLOW_IMPORTATO" +} +``` + +Poi in `scripts/fetch_latest_digest.py` sostituisci `https://n8n.as89.it` con il tuo dominio n8n e l'ID workflow con il tuo. + +### 4. Gli script audio + +Copia `scripts/` nel file system di Hermes. Serve `ffmpeg` sul sistema (`apt install ffmpeg`). + +Prova del motore TTS: + +```bash +cd /hn-oss-digest +printf 'Test della voce. Funziona!\n' > /tmp/t.md +python3 narrate_to_audio.py /tmp/t.md /tmp/t.mp3 +``` + +### 5. Il cron Hermes (il vocalino) + +Nella chat con Hermes, chiedi di creare un cron job con queste istruzioni (regolane i dettagli): + +> Ogni giorno alle 20:20 ora italiana: +> 1. esegui `fetch_latest_digest.py`; se l'executionId Γ¨ giΓ  in `narrated-ids.txt`, fermati +> 2. leggi `latest-digest.json` e scrivi `narration.md`: TUTTI i progetti, uno per uno, stile podcast (mai elenco meccanico), ~1 minuto per progetto, chiudendo OGNI progetto con "il nome da cercare Γ¨: " +> 3. esegui `narrate_to_audio.py` β†’ `digest-audio.mp3` +> 4. consegna in chat il file come messaggio vocale + +⚠️ **Trappola del fuso**: lo scheduler di Hermes interpreta gli orari naturali come **UTC**. Per le 20:20 italiane il cron Γ¨ `20 18 * * *`. Verifica SEMPRE il campo `next_run_at` dopo la creazione. + +--- + +## I cinque pitfall reali (ci hanno costo ore β€” a te costeranno minuti) + +### 1. Il Code node di n8n (task runner) Γ¨ una sandbox povera +- ❌ niente `fetch`, niente `URL`, niente `AbortSignal`, niente `process` +- ❌ `require('https')` β†’ "Module disallowed" +- βœ… `this.helpers.httpRequest({url, timeout})` β€” **l'unico modo** di chiamare HTTP da un Code node +- Parse degli URL a **regex** (vedi `readmeUrlFor` nel workflow), non con `new URL()` + +### 2. Il codice del Code node Γ¨ CACHE per workflow ID +Modificare `jsCode` via API (o UI!) non aggiorna l'esecuzione: il task runner tiene il codice della **prima compilazione** per quel workflow ID. Il trigger nuovo parte, ma esegue il codice vecchio β€” silenziosamente. +**Fix**: a ogni cambio di logica crea un workflow NUOVO (ID nuovo) e ritira il vecchio. Per debuggare il sandbox: workflow TMP monouso con `jsCode` di sonda (`typeof fetch`, lista globals) e leggi il runData dell'esecuzione. + +### 3. I fail silenziosi sono il nemico +Il primo fetch README ingoiava gli errori con `catch {}` e *sembrava* funzionare: 0 download su 50 senza un messaggio. **Regola**: ogni operazione fallibile registra il proprio errore per item (`readmeErr = 'HTTP ' + r.status`), e leggi le esecuzioni con `?includeData=true`. + +### 4. Fusi orari: tre livelli di trappola +- n8n Schedule Trigger usa il timezone delle **settings del workflow** (mettilo su Europe/Rome) +- Hermes cron interpreta gli orari naturali come **UTC** +- I cron one-shot di test si impostano SOLO dopo aver guardato `date`: un "test alle 10:35" salvato alle 11:15 non scatta mai + +### 5. Il nodo DataTable nativo di n8n puΓ² essere rotto +Con i task runner abilitati, il nodo DataTable crasha (`Cannot read properties of undefined (reading 'execute')`) a prescindere dai parametri. Il workflow usa invece **due HTTP Request** che chiamano la REST API di n8n stessa (`GET/POST /data-tables//rows`) con l'header `X-N8N-API-KEY` presa dal nodo Credenziali. Funziona da sempre e dΓ  anche lo storico (append-only). +Nota: l'API pubblica delle data tables supporta solo GET e POST righe (corpo `{"data":[{...}]}`) β€” niente update/delete per riga: ecco perchΓ© il checkpoint Γ¨ append-only (leggi il MAX, non l'ultima). + +Bonus β€” **quota TTS Gemini**: il free tier Γ¨ ~10 richieste/giorno **per modello TTS** (il 429 dura fino a ~02:00 ora italiana). `narrate_to_audio.py` fa cascata su 4 modelli TTS (`MODELS`) e cache dei segmenti: il retry costa zero. + +--- + +## File + +``` +n8n/workflow-digest-oss.json workflow n8n completo (chiavi rimosse β€” reinseriscile) +scripts/fetch_latest_digest.py prende l'ultimo digest riuscito dall'API n8n β†’ latest-digest.json +scripts/narrate_to_audio.py narrazione β†’ MP3 (segmentazione, cache, cascata TTS, anti-divagazione) +``` + +## Personalizzazioni rapide + +| Vuoi... | Tocca... | +|---|---| +| descrizioni piΓΉ lunghe/corte | nodo `Seleziona OSS` β†’ prompt ("4 a 6 frasi", "400 caratteri") | +| voce piΓΉ lenta/rapida | `STYLE` in `narrate_to_audio.py` (es. aggiungi "speak slowly") | +| altra voce | `voiceName` nel TTS (Kore, Charon, Orus, Sulafat, Puck…) β€” fatti un provino! | +| piΓΉ/meno progetti | filtri nel Code `Seleziona OSS` (soglia punti, keyword OSS) | +| orari | nodo `Ogni 12 ore` in n8n; cron del job Hermes | + +## Licenza e note + +- Codice e manuale: MIT β€” prendi, adatta, condividi. +- Le API gratuite cambiano limiti spesso: i numeri di questo README erano veri a ottobre 2026. +- Se il progetto ti Γ¨ utile, il ringraziamento giusto Γ¨ un contributo a [Hermes Agent](https://github.com/NousResearch/hermes-agent) o a [n8n](https://github.com/n8n-io/n8n) β€” su cui tutto questo Γ¨ costruito. diff --git a/n8n/workflow-digest-oss.json b/n8n/workflow-digest-oss.json new file mode 100644 index 0000000..69865ea --- /dev/null +++ b/n8n/workflow-digest-oss.json @@ -0,0 +1,1025 @@ +{ + "id": "tZwGIY9rcdybQLPr", + "name": "Digest OSS Hacker News", + "description": null, + "active": true, + "activeVersionId": "0a920067-acb9-49ef-9fa7-fe8a7dfdd006", + "createdAt": "2026-10-15T09:32:21.737Z", + "updatedAt": "2026-10-15T12:04:40.776Z", + "isArchived": false, + "versionId": "0a920067-acb9-49ef-9fa7-fe8a7dfdd006", + "versionCounter": 2, + "sourceWorkflowId": null, + "triggerCount": 1, + "nodes": [ + { + "id": "006047cd-339c-4921-9f30-060c0666089d", + "name": "Credenziali", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + -420, + 0 + ], + "parameters": { + "assignments": { + "assignments": [ + { + "id": "c1", + "name": "gemini_key", + "value": "INSERISCI_GEMINI_KEY", + "type": "string" + }, + { + "id": "c2", + "name": "telegram_token", + "value": "INSERISCI_TELEGRAM_TOKEN", + "type": "string" + }, + { + "id": "c3", + "name": "telegram_chat_id", + "value": "INSERISCI_CHAT_ID", + "type": "string" + }, + { + "id": "c4", + "name": "n8n_key", + "value": "INSERISCI_N8N_API_KEY", + "type": "string" + } + ] + }, + "options": {} + } + }, + { + "id": "4b323904-fa5b-4c1c-ae0d-8445a5ed387a", + "name": "Ogni 12 ore", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.2, + "position": [ + -620, + 0 + ], + "parameters": { + "rule": { + "interval": [ + { + "field": "cronExpression", + "expression": "=20 14 15 10 *" + } + ] + } + } + }, + { + "id": "212a624e-34e7-43a7-bce2-f751712fd56b", + "name": "Chat ID impostato?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "position": [ + -220, + 0 + ], + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose" + }, + "conditions": [ + { + "id": "haschat", + "leftValue": "={{ $json.telegram_chat_id }}", + "rightValue": "", + "operator": { + "type": "string", + "operation": "notEmpty", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + } + }, + { + "id": "fceddced-e50d-4f7f-b3c2-1b5ffc94c6c9", + "name": "Leggi checkpoint", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 5000, + "position": [ + 0, + -100 + ], + "parameters": { + "url": "https://n8n.as89.it/api/v1/data-tables/qkbTUn1IKaltUfVo/rows", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "X-N8N-API-KEY", + "value": "={{ $('Credenziali').first().json.n8n_key }}" + } + ] + }, + "options": { + "timeout": 30000 + } + } + }, + { + "id": "d8602c73-6a43-429d-94db-36646bd10110", + "name": "Prendi Show HN", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 5000, + "position": [ + 220, + -100 + ], + "parameters": { + "url": "=https://hn.algolia.com/api/v1/search_by_date?tags=show_hn&numericFilters=created_at_i%3E{{ $json.sinceEpoch }}&hitsPerPage=1000", + "options": {} + } + }, + { + "id": "c0053636-8b6a-4a34-b872-91fb4f66c034", + "name": "Prendi Tutte le Storie", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 5000, + "position": [ + 440, + -100 + ], + "parameters": { + "url": "=https://hn.algolia.com/api/v1/search_by_date?tags=story&numericFilters=created_at_i%3E{{ $('Normalizza checkpoint').first().json.sinceEpoch }}&hitsPerPage=1000", + "options": {} + } + }, + { + "id": "f8d03a8e-454e-424c-99d8-b825df23d560", + "name": "Seleziona OSS", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 660, + -100 + ], + "parameters": { + "jsCode": "const REPO_RE = /github\\.com|gitlab\\.com|codeberg\\.org|sr\\.ht|sourcehut/i;\nconst OSS_TITLE_RE = /\\b(open ?sourc|foss|self.?-?host|local.?first|free software|libre)\\b/i;\nconst score = h => (h.points || 0) * 3 + (h.num_comments || 0);\nconst norm = t => (t || '').replace(/^show hn:\\s*/i, '').toLowerCase().trim().replace(/\\s+/g, ' ');\nconst link = h => h.url || ('https://news.ycombinator.com/item?id=' + h.objectID);\n\nconst showAll = $('Prendi Show HN').first().json.hits || [];\nconst show = showAll.filter(h => REPO_RE.test(h.url || '') || OSS_TITLE_RE.test(h.title || ''));\nconst stories = $('Prendi Tutte le Storie').first().json.hits || [];\nconst showTitles = new Set(show.map(h => norm(h.title)));\nconst repos = stories.filter(h => REPO_RE.test(h.url || '') && !showTitles.has(norm(h.title)));\nconst news = stories.filter(h => !REPO_RE.test(h.url || '') && OSS_TITLE_RE.test(h.title || '') && (h.points || 0) >= 3);\n\nconst all = [\n ...show.map(h => ({ ...h, _cat: 'show' })),\n ...repos.map(h => ({ ...h, _cat: 'repo' })),\n ...news.map(h => ({ ...h, _cat: 'news' })),\n];\nconst seen = new Set();\nconst dedup = [];\nfor (const h of all) { const n = norm(h.title); if (!seen.has(n)) { seen.add(n); dedup.push(h); } }\ndedup.sort((a, b) => score(b) - score(a));\n\nconst readmeUrlFor = (u) => {\n try {\n const m = String(u || '').match(/^https?:\\/\\/([^\\/]+)\\/([^\\/?#]+)\\/([^\\/?#]+)/i);\n if (!m) return null;\n const host = m[1].toLowerCase();\n const repo = m[2] + '/' + m[3];\n if (host === 'github.com' || host.endsWith('.github.com')) return 'https://raw.githubusercontent.com/' + repo + '/HEAD/README.md';\n if (host === 'gitlab.com' || host.endsWith('.gitlab.com')) return 'https://gitlab.com/' + repo + '/-/raw/HEAD/README.md';\n if (host === 'codeberg.org') return 'https://codeberg.org/' + repo + '/raw/branch/HEAD/README.md';\n } catch (e) {}\n return null;\n};\n\nconst items = await Promise.all(dedup.map(async (h) => {\n const it = { id: h.objectID, title: h.title, url: link(h), points: h.points || 0, comments: h.num_comments || 0, cat: h._cat };\n const ru = readmeUrlFor(it.url);\n if (ru) {\n try {\n const r = await this.helpers.httpRequest({ url: ru, timeout: 8000 });\n it.readme = String(r).slice(0, 1200);\n } catch (e) {\n it.readmeErr = String((e && e.message) || e).slice(0, 80);\n }\n }\n return it;\n}));\nconst conReadme = items.filter(i => i.readme).length;\n\nconst sinceOld = $('Normalizza checkpoint').first().json.sinceEpoch;\nconst sinceNew = Math.max(sinceOld, ...all.map(h => h.created_at_i || 0));\nconst newItemCount = all.filter(h => (h.created_at_i || 0) > sinceOld).length;\n\nconst prompt = 'Sei un redattore tecnico italiano specializzato in software open source. ' +\n 'Ricevi un elenco di item da Hacker News. ALCUNI item hanno un campo \"readme\": il testo reale del README del progetto, scaricato dal repository. ' +\n 'Per OGNI item produci: name (nome breve; se il README rivela il vero nome del progetto, usa quello), ' +\n 'desc, points, comments, url IDENTICO a quello ricevuto. ' +\n 'REGOLE per desc: se l\\'item ha il campo readme, scrivi da 4 a 6 frasi in italiano (almeno 400 caratteri): ' +\n 'il problema che risolve, come funziona in pratica, a chi serve, un dettaglio tecnico notevole se presente. ' +\n 'Basa le frasi SOLO sul README: non inventare nulla. ' +\n 'Se il README mostra che il progetto non e\\' open source o e\\' il client di un servizio a pagamento, dillo esplicitamente. ' +\n 'Se l\\'item NON ha il campo readme, scrivi 2 frasi dal titolo e ANTICIPALE con il marcatore [non verificato]. ' +\n 'Niente marketing, niente emoji (eccetto il marcatore). ' +\n 'NON OMETTERE NESSUN ITEM: la risposta deve contenere esattamente ' + items.length + ' item in totale. ' +\n 'Raggruppa per categoria: show, repo, news, come nel campo cat. ' +\n 'Ordina ogni categoria per importanza. ' +\n 'Rispondi SOLO con JSON valido, forma esatta: ' +\n '{\"sections\":{\"show\":[{\"name\":\"\",\"desc\":\"\",\"points\":0,\"comments\":0,\"url\":\"\"}],\"repo\":[],\"news\":[]}} ' +\n '\\n\\nELENCO ITEM (JSON):\\n' + JSON.stringify(items);\n\nreturn [{ json: { items, count: items.length, conReadme, sinceOld, sinceNew, newItemCount, prompt } }];" + } + }, + { + "id": "f082ed2c-c61a-49c7-b101-44be06b278e4", + "name": "Gemini riassume", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 10000, + "onError": "continueErrorOutput", + "position": [ + 880, + -100 + ], + "parameters": { + "method": "POST", + "url": "=https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key={{ $('Credenziali').first().json.gemini_key }}", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ contents: [{ parts: [{ text: $json.prompt }] }], generationConfig: { responseMimeType: 'application/json', temperature: 0.4, maxOutputTokens: 30000, thinkingConfig: { thinkingBudget: 0 } } }) }}", + "options": { + "timeout": 300000 + } + } + }, + { + "id": "fbdc92fb-e069-4889-8789-83987ebad447", + "name": "Prepara messaggi", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1100, + -100 + ], + "parameters": { + "jsCode": "const pick = (name) => { try { const j = $(name).first().json; if (j && j.candidates) return j; } catch (e) {} return null; };\nconst resp = pick('Gemini riassume') || pick('Gemini lite (fallback)');\n\nconst raw = ((resp.candidates || [])[0] || {}).content;\nconst text = (raw && Array.isArray(raw.parts)) ? raw.parts.map(p => p.text || '').join('') : '';\nlet digest;\ntry { digest = JSON.parse(text); }\ncatch (e) {\n const m = text.match(/\\{[\\s\\S]*\\}/);\n if (!m) throw new Error('Gemini non ha restituito JSON valido: ' + text.slice(0, 300));\n digest = JSON.parse(m[0]);\n}\nconst meta = $('Seleziona OSS').first().json;\nconst secs = (digest && digest.sections) || {};\nconst total = ['show', 'repo', 'news'].reduce((n, k) => n + ((secs[k] || []).length), 0);\n\nconst date = new Date();\nconst quando = date.toLocaleString('it-IT', { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Rome' });\nconst header = '\\ud83e\\udd8a DIGEST OPEN SOURCE da Hacker News\\n' + quando +\n '\\n' + meta.count + ' progetti e novita OSS delle ultime 12 ore (Show HN + repo + release). Zero tagli.' +\n (total !== meta.count ? '\\n(attenzione: Gemini ne ha restituiti ' + total + ' β€” eventuali mancanti non si riproporranno nella prossima finestra)' : '') + '\\n\\n';\n\nconst TITLES = { show: '\\ud83d\\ude80 NUOVI PROGETTI OPEN SOURCE (Show HN)', repo: '\\ud83d\\udd17 PROGETTI EMERSI NELLE DISCUSSIONI', news: '\\ud83d\\udcf0 RELEASE E NOVITA SU PROGETTI ESISTENTI' };\nconst blocks = [];\nfor (const k of ['show', 'repo', 'news']) {\n const arr = secs[k] || [];\n if (!arr.length) continue;\n blocks.push(TITLES[k]);\n for (const it of arr) {\n let line = '\\u2022 ' + (it.name || '?');\n if (it.desc) line += ' \\u2014 ' + it.desc;\n line += ' [' + (it.points || 0) + '\\u2191/' + (it.comments || 0) + '\\ud83d\\udcac]';\n blocks.push(line);\n blocks.push(it.url || '');\n }\n blocks.push('');\n}\nconst MAX = 3800;\nconst msgs = [];\nlet cur = header;\nfor (const b of blocks) {\n if (cur.length + b.length + 1 > MAX) { msgs.push(cur.trimEnd()); cur = '(segue)\\n' + b + '\\n'; }\n else cur += b + '\\n';\n}\nif (cur.trim()) msgs.push(cur.trimEnd());\n\nconst cred = $('Credenziali').first().json;\nreturn msgs.map((t, i) => ({ json: {\n chat_id: cred.telegram_chat_id, text: t,\n seq: i + 1, totale: msgs.length, sinceNew: meta.sinceNew,\n} }));" + } + }, + { + "id": "e232c24f-b68c-4b75-985f-1da15ffe4f7f", + "name": "Invia su Telegram", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 5000, + "position": [ + 1320, + -100 + ], + "parameters": { + "method": "POST", + "url": "=https://api.telegram.org/bot{{ $('Credenziali').first().json.telegram_token }}/sendMessage", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ chat_id: Number($json.chat_id), text: $json.text }) }}", + "options": { + "timeout": 60000 + } + } + }, + { + "id": "ce380b59-8b69-4ab1-a2a2-4a8c6311f70d", + "name": "Aggiorna checkpoint", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "executeOnce": true, + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 5000, + "position": [ + 1540, + -100 + ], + "parameters": { + "method": "POST", + "url": "https://n8n.as89.it/api/v1/data-tables/qkbTUn1IKaltUfVo/rows", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "X-N8N-API-KEY", + "value": "={{ $('Credenziali').first().json.n8n_key }}" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ data: [{ sinceEpoch: $('Prepara messaggi').first().json.sinceNew }] }) }}", + "options": { + "timeout": 30000 + } + } + }, + { + "id": "c54b86c0-058b-48b3-9988-f80e0d5f6b6d", + "name": "Avvisa in chat Hermes", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 0, + 140 + ], + "parameters": { + "method": "POST", + "url": "=https://api.telegram.org/bot{{ $('Credenziali').first().json.telegram_token }}/sendMessage", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ chat_id: Number($('Credenziali').first().json.telegram_chat_id), text: 'Attiva il digest: apri t.me/Hn12h_bot e premi AVVIA, poi scrivi qui a Vale la parola via. (Messaggio automatico del workflow Digest OSS: chat ID non ancora configurato.)' }) }}", + "options": { + "timeout": 60000 + } + } + }, + { + "id": "aa3e286c-8675-4f05-bb31-3246d4013ca8", + "name": "Normalizza checkpoint", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 110, + -100 + ], + "parameters": { + "jsCode": "const rows = ($('Leggi checkpoint').first().json.data || []);\nconst sinceEpoch = rows.length ? Math.max(...rows.map(r => r.sinceEpoch || 0)) : 0;\nreturn [{ json: { sinceEpoch } }];" + } + }, + { + "id": "c5e7e303-ef49-4bd1-a519-ada163a0ab77", + "name": "Gemini lite (fallback)", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 15000, + "position": [ + 990, + 60 + ], + "parameters": { + "method": "POST", + "url": "=https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent?key={{ $('Credenziali').first().json.gemini_key }}", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ contents: [{ parts: [{ text: $('Seleziona OSS').first().json.prompt }] }], generationConfig: { responseMimeType: 'application/json', temperature: 0.4, maxOutputTokens: 30000, thinkingConfig: { thinkingBudget: 0 } } }) }}", + "options": { + "timeout": 300000 + } + } + } + ], + "connections": { + "Ogni 12 ore": { + "main": [ + [ + { + "node": "Credenziali", + "type": "main", + "index": 0 + } + ] + ] + }, + "Credenziali": { + "main": [ + [ + { + "node": "Chat ID impostato?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Chat ID impostato?": { + "main": [ + [ + { + "node": "Leggi checkpoint", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Avvisa in chat Hermes", + "type": "main", + "index": 0 + } + ] + ] + }, + "Leggi checkpoint": { + "main": [ + [ + { + "node": "Normalizza checkpoint", + "type": "main", + "index": 0 + } + ] + ] + }, + "Prendi Show HN": { + "main": [ + [ + { + "node": "Prendi Tutte le Storie", + "type": "main", + "index": 0 + } + ] + ] + }, + "Prendi Tutte le Storie": { + "main": [ + [ + { + "node": "Seleziona OSS", + "type": "main", + "index": 0 + } + ] + ] + }, + "Seleziona OSS": { + "main": [ + [ + { + "node": "Gemini riassume", + "type": "main", + "index": 0 + } + ] + ] + }, + "Gemini riassume": { + "main": [ + [ + { + "node": "Prepara messaggi", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Gemini lite (fallback)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Prepara messaggi": { + "main": [ + [ + { + "node": "Invia su Telegram", + "type": "main", + "index": 0 + } + ] + ] + }, + "Invia su Telegram": { + "main": [ + [ + { + "node": "Aggiorna checkpoint", + "type": "main", + "index": 0 + } + ] + ] + }, + "Normalizza checkpoint": { + "main": [ + [ + { + "node": "Prendi Show HN", + "type": "main", + "index": 0 + } + ] + ] + }, + "Gemini lite (fallback)": { + "main": [ + [ + { + "node": "Prepara messaggi", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "nodeGroups": [], + "settings": { + "timezone": "Europe/Rome", + "executionOrder": "v1" + }, + "staticData": { + "node:Ogni 12 ore": { + "recurrenceRules": [ + null + ], + "recurrenceRuleSignatures": [ + null + ] + } + }, + "pinData": null, + "meta": null, + "tags": [], + "shared": [ + { + "role": "workflow:owner", + "workflowId": "tZwGIY9rcdybQLPr", + "projectId": "haxNGpGWGLCeacr6", + "project": { + "id": "haxNGpGWGLCeacr6", + "name": "Andrea Sarego ", + "type": "personal", + "icon": null, + "description": null, + "customTelemetryTags": [], + "creatorId": "e3520907-ef65-42c2-a65a-27100f80e8c5", + "createdAt": "2026-09-06T05:31:25.651Z", + "updatedAt": "2026-09-06T05:39:36.353Z" + }, + "createdAt": "2026-10-15T09:32:21.737Z", + "updatedAt": "2026-10-15T09:32:21.737Z" + } + ], + "activeVersion": { + "versionId": "0a920067-acb9-49ef-9fa7-fe8a7dfdd006", + "workflowId": "tZwGIY9rcdybQLPr", + "nodes": [ + { + "id": "006047cd-339c-4921-9f30-060c0666089d", + "name": "Credenziali", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + -420, + 0 + ], + "parameters": { + "assignments": { + "assignments": [ + { + "id": "c1", + "name": "gemini_key", + "value": "INSERISCI_GEMINI_KEY", + "type": "string" + }, + { + "id": "c2", + "name": "telegram_token", + "value": "INSERISCI_TELEGRAM_TOKEN", + "type": "string" + }, + { + "id": "c3", + "name": "telegram_chat_id", + "value": "INSERISCI_CHAT_ID", + "type": "string" + }, + { + "id": "c4", + "name": "n8n_key", + "value": "INSERISCI_N8N_API_KEY", + "type": "string" + } + ] + }, + "options": {} + } + }, + { + "id": "4b323904-fa5b-4c1c-ae0d-8445a5ed387a", + "name": "Ogni 12 ore", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.2, + "position": [ + -620, + 0 + ], + "parameters": { + "rule": { + "interval": [ + { + "field": "cronExpression", + "expression": "=20 14 15 10 *" + } + ] + } + } + }, + { + "id": "212a624e-34e7-43a7-bce2-f751712fd56b", + "name": "Chat ID impostato?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "position": [ + -220, + 0 + ], + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose" + }, + "conditions": [ + { + "id": "haschat", + "leftValue": "={{ $json.telegram_chat_id }}", + "rightValue": "", + "operator": { + "type": "string", + "operation": "notEmpty", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + } + }, + { + "id": "fceddced-e50d-4f7f-b3c2-1b5ffc94c6c9", + "name": "Leggi checkpoint", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 5000, + "position": [ + 0, + -100 + ], + "parameters": { + "url": "https://n8n.as89.it/api/v1/data-tables/qkbTUn1IKaltUfVo/rows", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "X-N8N-API-KEY", + "value": "={{ $('Credenziali').first().json.n8n_key }}" + } + ] + }, + "options": { + "timeout": 30000 + } + } + }, + { + "id": "d8602c73-6a43-429d-94db-36646bd10110", + "name": "Prendi Show HN", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 5000, + "position": [ + 220, + -100 + ], + "parameters": { + "url": "=https://hn.algolia.com/api/v1/search_by_date?tags=show_hn&numericFilters=created_at_i%3E{{ $json.sinceEpoch }}&hitsPerPage=1000", + "options": {} + } + }, + { + "id": "c0053636-8b6a-4a34-b872-91fb4f66c034", + "name": "Prendi Tutte le Storie", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 5000, + "position": [ + 440, + -100 + ], + "parameters": { + "url": "=https://hn.algolia.com/api/v1/search_by_date?tags=story&numericFilters=created_at_i%3E{{ $('Normalizza checkpoint').first().json.sinceEpoch }}&hitsPerPage=1000", + "options": {} + } + }, + { + "id": "f8d03a8e-454e-424c-99d8-b825df23d560", + "name": "Seleziona OSS", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 660, + -100 + ], + "parameters": { + "jsCode": "const REPO_RE = /github\\.com|gitlab\\.com|codeberg\\.org|sr\\.ht|sourcehut/i;\nconst OSS_TITLE_RE = /\\b(open ?sourc|foss|self.?-?host|local.?first|free software|libre)\\b/i;\nconst score = h => (h.points || 0) * 3 + (h.num_comments || 0);\nconst norm = t => (t || '').replace(/^show hn:\\s*/i, '').toLowerCase().trim().replace(/\\s+/g, ' ');\nconst link = h => h.url || ('https://news.ycombinator.com/item?id=' + h.objectID);\n\nconst showAll = $('Prendi Show HN').first().json.hits || [];\nconst show = showAll.filter(h => REPO_RE.test(h.url || '') || OSS_TITLE_RE.test(h.title || ''));\nconst stories = $('Prendi Tutte le Storie').first().json.hits || [];\nconst showTitles = new Set(show.map(h => norm(h.title)));\nconst repos = stories.filter(h => REPO_RE.test(h.url || '') && !showTitles.has(norm(h.title)));\nconst news = stories.filter(h => !REPO_RE.test(h.url || '') && OSS_TITLE_RE.test(h.title || '') && (h.points || 0) >= 3);\n\nconst all = [\n ...show.map(h => ({ ...h, _cat: 'show' })),\n ...repos.map(h => ({ ...h, _cat: 'repo' })),\n ...news.map(h => ({ ...h, _cat: 'news' })),\n];\nconst seen = new Set();\nconst dedup = [];\nfor (const h of all) { const n = norm(h.title); if (!seen.has(n)) { seen.add(n); dedup.push(h); } }\ndedup.sort((a, b) => score(b) - score(a));\n\nconst readmeUrlFor = (u) => {\n try {\n const m = String(u || '').match(/^https?:\\/\\/([^\\/]+)\\/([^\\/?#]+)\\/([^\\/?#]+)/i);\n if (!m) return null;\n const host = m[1].toLowerCase();\n const repo = m[2] + '/' + m[3];\n if (host === 'github.com' || host.endsWith('.github.com')) return 'https://raw.githubusercontent.com/' + repo + '/HEAD/README.md';\n if (host === 'gitlab.com' || host.endsWith('.gitlab.com')) return 'https://gitlab.com/' + repo + '/-/raw/HEAD/README.md';\n if (host === 'codeberg.org') return 'https://codeberg.org/' + repo + '/raw/branch/HEAD/README.md';\n } catch (e) {}\n return null;\n};\n\nconst items = await Promise.all(dedup.map(async (h) => {\n const it = { id: h.objectID, title: h.title, url: link(h), points: h.points || 0, comments: h.num_comments || 0, cat: h._cat };\n const ru = readmeUrlFor(it.url);\n if (ru) {\n try {\n const r = await this.helpers.httpRequest({ url: ru, timeout: 8000 });\n it.readme = String(r).slice(0, 1200);\n } catch (e) {\n it.readmeErr = String((e && e.message) || e).slice(0, 80);\n }\n }\n return it;\n}));\nconst conReadme = items.filter(i => i.readme).length;\n\nconst sinceOld = $('Normalizza checkpoint').first().json.sinceEpoch;\nconst sinceNew = Math.max(sinceOld, ...all.map(h => h.created_at_i || 0));\nconst newItemCount = all.filter(h => (h.created_at_i || 0) > sinceOld).length;\n\nconst prompt = 'Sei un redattore tecnico italiano specializzato in software open source. ' +\n 'Ricevi un elenco di item da Hacker News. ALCUNI item hanno un campo \"readme\": il testo reale del README del progetto, scaricato dal repository. ' +\n 'Per OGNI item produci: name (nome breve; se il README rivela il vero nome del progetto, usa quello), ' +\n 'desc, points, comments, url IDENTICO a quello ricevuto. ' +\n 'REGOLE per desc: se l\\'item ha il campo readme, scrivi da 4 a 6 frasi in italiano (almeno 400 caratteri): ' +\n 'il problema che risolve, come funziona in pratica, a chi serve, un dettaglio tecnico notevole se presente. ' +\n 'Basa le frasi SOLO sul README: non inventare nulla. ' +\n 'Se il README mostra che il progetto non e\\' open source o e\\' il client di un servizio a pagamento, dillo esplicitamente. ' +\n 'Se l\\'item NON ha il campo readme, scrivi 2 frasi dal titolo e ANTICIPALE con il marcatore [non verificato]. ' +\n 'Niente marketing, niente emoji (eccetto il marcatore). ' +\n 'NON OMETTERE NESSUN ITEM: la risposta deve contenere esattamente ' + items.length + ' item in totale. ' +\n 'Raggruppa per categoria: show, repo, news, come nel campo cat. ' +\n 'Ordina ogni categoria per importanza. ' +\n 'Rispondi SOLO con JSON valido, forma esatta: ' +\n '{\"sections\":{\"show\":[{\"name\":\"\",\"desc\":\"\",\"points\":0,\"comments\":0,\"url\":\"\"}],\"repo\":[],\"news\":[]}} ' +\n '\\n\\nELENCO ITEM (JSON):\\n' + JSON.stringify(items);\n\nreturn [{ json: { items, count: items.length, conReadme, sinceOld, sinceNew, newItemCount, prompt } }];" + } + }, + { + "id": "f082ed2c-c61a-49c7-b101-44be06b278e4", + "name": "Gemini riassume", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 10000, + "onError": "continueErrorOutput", + "position": [ + 880, + -100 + ], + "parameters": { + "method": "POST", + "url": "=https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key={{ $('Credenziali').first().json.gemini_key }}", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ contents: [{ parts: [{ text: $json.prompt }] }], generationConfig: { responseMimeType: 'application/json', temperature: 0.4, maxOutputTokens: 30000, thinkingConfig: { thinkingBudget: 0 } } }) }}", + "options": { + "timeout": 300000 + } + } + }, + { + "id": "fbdc92fb-e069-4889-8789-83987ebad447", + "name": "Prepara messaggi", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1100, + -100 + ], + "parameters": { + "jsCode": "const pick = (name) => { try { const j = $(name).first().json; if (j && j.candidates) return j; } catch (e) {} return null; };\nconst resp = pick('Gemini riassume') || pick('Gemini lite (fallback)');\n\nconst raw = ((resp.candidates || [])[0] || {}).content;\nconst text = (raw && Array.isArray(raw.parts)) ? raw.parts.map(p => p.text || '').join('') : '';\nlet digest;\ntry { digest = JSON.parse(text); }\ncatch (e) {\n const m = text.match(/\\{[\\s\\S]*\\}/);\n if (!m) throw new Error('Gemini non ha restituito JSON valido: ' + text.slice(0, 300));\n digest = JSON.parse(m[0]);\n}\nconst meta = $('Seleziona OSS').first().json;\nconst secs = (digest && digest.sections) || {};\nconst total = ['show', 'repo', 'news'].reduce((n, k) => n + ((secs[k] || []).length), 0);\n\nconst date = new Date();\nconst quando = date.toLocaleString('it-IT', { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Rome' });\nconst header = '\\ud83e\\udd8a DIGEST OPEN SOURCE da Hacker News\\n' + quando +\n '\\n' + meta.count + ' progetti e novita OSS delle ultime 12 ore (Show HN + repo + release). Zero tagli.' +\n (total !== meta.count ? '\\n(attenzione: Gemini ne ha restituiti ' + total + ' β€” eventuali mancanti non si riproporranno nella prossima finestra)' : '') + '\\n\\n';\n\nconst TITLES = { show: '\\ud83d\\ude80 NUOVI PROGETTI OPEN SOURCE (Show HN)', repo: '\\ud83d\\udd17 PROGETTI EMERSI NELLE DISCUSSIONI', news: '\\ud83d\\udcf0 RELEASE E NOVITA SU PROGETTI ESISTENTI' };\nconst blocks = [];\nfor (const k of ['show', 'repo', 'news']) {\n const arr = secs[k] || [];\n if (!arr.length) continue;\n blocks.push(TITLES[k]);\n for (const it of arr) {\n let line = '\\u2022 ' + (it.name || '?');\n if (it.desc) line += ' \\u2014 ' + it.desc;\n line += ' [' + (it.points || 0) + '\\u2191/' + (it.comments || 0) + '\\ud83d\\udcac]';\n blocks.push(line);\n blocks.push(it.url || '');\n }\n blocks.push('');\n}\nconst MAX = 3800;\nconst msgs = [];\nlet cur = header;\nfor (const b of blocks) {\n if (cur.length + b.length + 1 > MAX) { msgs.push(cur.trimEnd()); cur = '(segue)\\n' + b + '\\n'; }\n else cur += b + '\\n';\n}\nif (cur.trim()) msgs.push(cur.trimEnd());\n\nconst cred = $('Credenziali').first().json;\nreturn msgs.map((t, i) => ({ json: {\n chat_id: cred.telegram_chat_id, text: t,\n seq: i + 1, totale: msgs.length, sinceNew: meta.sinceNew,\n} }));" + } + }, + { + "id": "e232c24f-b68c-4b75-985f-1da15ffe4f7f", + "name": "Invia su Telegram", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 5000, + "position": [ + 1320, + -100 + ], + "parameters": { + "method": "POST", + "url": "=https://api.telegram.org/bot{{ $('Credenziali').first().json.telegram_token }}/sendMessage", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ chat_id: Number($json.chat_id), text: $json.text }) }}", + "options": { + "timeout": 60000 + } + } + }, + { + "id": "ce380b59-8b69-4ab1-a2a2-4a8c6311f70d", + "name": "Aggiorna checkpoint", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "executeOnce": true, + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 5000, + "position": [ + 1540, + -100 + ], + "parameters": { + "method": "POST", + "url": "https://n8n.as89.it/api/v1/data-tables/qkbTUn1IKaltUfVo/rows", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "X-N8N-API-KEY", + "value": "={{ $('Credenziali').first().json.n8n_key }}" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ data: [{ sinceEpoch: $('Prepara messaggi').first().json.sinceNew }] }) }}", + "options": { + "timeout": 30000 + } + } + }, + { + "id": "c54b86c0-058b-48b3-9988-f80e0d5f6b6d", + "name": "Avvisa in chat Hermes", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 0, + 140 + ], + "parameters": { + "method": "POST", + "url": "=https://api.telegram.org/bot{{ $('Credenziali').first().json.telegram_token }}/sendMessage", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ chat_id: Number($('Credenziali').first().json.telegram_chat_id), text: 'Attiva il digest: apri t.me/Hn12h_bot e premi AVVIA, poi scrivi qui a Vale la parola via. (Messaggio automatico del workflow Digest OSS: chat ID non ancora configurato.)' }) }}", + "options": { + "timeout": 60000 + } + } + }, + { + "id": "aa3e286c-8675-4f05-bb31-3246d4013ca8", + "name": "Normalizza checkpoint", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 110, + -100 + ], + "parameters": { + "jsCode": "const rows = ($('Leggi checkpoint').first().json.data || []);\nconst sinceEpoch = rows.length ? Math.max(...rows.map(r => r.sinceEpoch || 0)) : 0;\nreturn [{ json: { sinceEpoch } }];" + } + }, + { + "id": "c5e7e303-ef49-4bd1-a519-ada163a0ab77", + "name": "Gemini lite (fallback)", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 15000, + "position": [ + 990, + 60 + ], + "parameters": { + "method": "POST", + "url": "=https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent?key={{ $('Credenziali').first().json.gemini_key }}", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ contents: [{ parts: [{ text: $('Seleziona OSS').first().json.prompt }] }], generationConfig: { responseMimeType: 'application/json', temperature: 0.4, maxOutputTokens: 30000, thinkingConfig: { thinkingBudget: 0 } } }) }}", + "options": { + "timeout": 300000 + } + } + } + ], + "connections": { + "Ogni 12 ore": { + "main": [ + [ + { + "node": "Credenziali", + "type": "main", + "index": 0 + } + ] + ] + }, + "Credenziali": { + "main": [ + [ + { + "node": "Chat ID impostato?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Chat ID impostato?": { + "main": [ + [ + { + "node": "Leggi checkpoint", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Avvisa in chat Hermes", + "type": "main", + "index": 0 + } + ] + ] + }, + "Leggi checkpoint": { + "main": [ + [ + { + "node": "Normalizza checkpoint", + "type": "main", + "index": 0 + } + ] + ] + }, + "Prendi Show HN": { + "main": [ + [ + { + "node": "Prendi Tutte le Storie", + "type": "main", + "index": 0 + } + ] + ] + }, + "Prendi Tutte le Storie": { + "main": [ + [ + { + "node": "Seleziona OSS", + "type": "main", + "index": 0 + } + ] + ] + }, + "Seleziona OSS": { + "main": [ + [ + { + "node": "Gemini riassume", + "type": "main", + "index": 0 + } + ] + ] + }, + "Gemini riassume": { + "main": [ + [ + { + "node": "Prepara messaggi", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Gemini lite (fallback)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Prepara messaggi": { + "main": [ + [ + { + "node": "Invia su Telegram", + "type": "main", + "index": 0 + } + ] + ] + }, + "Invia su Telegram": { + "main": [ + [ + { + "node": "Aggiorna checkpoint", + "type": "main", + "index": 0 + } + ] + ] + }, + "Normalizza checkpoint": { + "main": [ + [ + { + "node": "Prendi Show HN", + "type": "main", + "index": 0 + } + ] + ] + }, + "Gemini lite (fallback)": { + "main": [ + [ + { + "node": "Prepara messaggi", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "nodeGroups": [], + "authors": "Andrea Sarego", + "name": null, + "description": null, + "autosaved": false, + "createdAt": "2026-10-15T12:04:40.781Z", + "updatedAt": "2026-10-15T12:04:40.781Z", + "workflowPublishHistory": [ + { + "id": 100, + "workflowId": "tZwGIY9rcdybQLPr", + "versionId": "0a920067-acb9-49ef-9fa7-fe8a7dfdd006", + "event": "activated", + "userId": "e3520907-ef65-42c2-a65a-27100f80e8c5", + "createdAt": "2026-10-15T12:04:41.034Z" + }, + { + "id": 101, + "workflowId": "tZwGIY9rcdybQLPr", + "versionId": "0a920067-acb9-49ef-9fa7-fe8a7dfdd006", + "event": "deactivated", + "userId": "e3520907-ef65-42c2-a65a-27100f80e8c5", + "createdAt": "2026-10-15T12:04:43.159Z" + }, + { + "id": 102, + "workflowId": "tZwGIY9rcdybQLPr", + "versionId": "0a920067-acb9-49ef-9fa7-fe8a7dfdd006", + "event": "activated", + "userId": "e3520907-ef65-42c2-a65a-27100f80e8c5", + "createdAt": "2026-10-15T12:04:46.358Z" + } + ] + } +} \ No newline at end of file diff --git a/scripts/fetch_latest_digest.py b/scripts/fetch_latest_digest.py new file mode 100644 index 0000000..4af9455 --- /dev/null +++ b/scripts/fetch_latest_digest.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Scarica il JSON dell'ultimo digest RIUSCITO del workflow v3 e lo salva in latest-digest.json. +Chiamato dal cronjob prima della narrazione. Include il testo dei README per la narrazione esaustiva.""" +import json, sys, urllib.request, urllib.error + +C = json.load(open('/opt/data/hn-oss-digest/creds.json')) +H = {'X-N8N-API-KEY': C['n8n_key']} +BASE = 'https://n8n.as89.it/api/v1' +WID = 'tZwGIY9rcdybQLPr' + +def http(url): + req = urllib.request.Request(url, headers=H) + with urllib.request.urlopen(req, timeout=60) as r: + return r.read().decode() + +d = json.loads(http(f'{BASE}/executions?limit=10&status=success&workflowId={WID}')) +data = d.get('data', []) +if not data: + print('NESSUNA ESECUZIONE RIUSCITA TROVATA') + sys.exit(1) + +eid = data[0]['id'] +x = json.loads(http(f'{BASE}/executions/{eid}?includeData=true')) +rd = x['data']['resultData']['runData'] +sel = rd['Seleziona OSS'][0]['data']['main'][0][0]['json'] + +def node_json(node): + """Primo json di output non vuoto del nodo, o None.""" + runs = rd.get(node) + if not runs: + return None + for conn in runs[0].get('data', {}).get('main', []): + if conn: + return conn[0].get('json') + return None + +g = node_json('Gemini riassume') +src = 'Gemini riassume' +if not g or 'candidates' not in g: + g = node_json('Gemini lite (fallback)') + src = 'Gemini lite (fallback)' +if not g or 'candidates' not in g: + print('NESSUN RIASSUNTO DISPONIBILE') + sys.exit(1) +print(f'sorgente riassunto: {src}') +txt = g['candidates'][0]['content']['parts'][0]['text'] +dj = json.loads(txt) + +# item originali (con il testo dei README) allegati alle sezioni finali +readme_by_url = {it.get('url', ''): it.get('readme', '') for it in sel.get('items', [])} +for k in ('show', 'repo', 'news'): + for it in dj.get('sections', {}).get(k, []): + it['readme'] = readme_by_url.get(it.get('url', ''), '') + +out = { + 'executionId': eid, + 'count': sel.get('count'), + 'conReadme': sel.get('conReadme'), + 'sinceOld': sel.get('sinceOld'), + 'sinceNew': sel.get('sinceNew'), + 'geminiSource': src, + 'sections': dj.get('sections', {}), +} +with open('/opt/data/hn-oss-digest/latest-digest.json', 'w') as f: + json.dump(out, f, ensure_ascii=False, indent=1) + +tot = sum(len(out['sections'].get(k, [])) for k in ('show', 'repo', 'news')) +conr = sum(1 for k in ('show', 'repo', 'news') for it in out['sections'].get(k, []) if it.get('readme')) +print(f'exec {eid}: {tot} item salvati in latest-digest.json | README allegati: {conr}') diff --git a/scripts/narrate_to_audio.py b/scripts/narrate_to_audio.py new file mode 100644 index 0000000..51b06a2 --- /dev/null +++ b/scripts/narrate_to_audio.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""narration.md -> digest-audio.mp3 β€” versione resilient: +- cache su disco dei segmenti WAV (riparte da dove era: i segmenti gia' fatti non si rifanno) +- retry con backoff esponenziale sui 429/503 (fino a 8 tentativi, attesa max ~5 min) +- pausa tra le chiamate per stare lontano dal rate limit +- guardia anti-divagazione (durata attesa vs reale) +Uso: python3 narrate_to_audio.py [narration.md] [output.mp3]""" +import base64, hashlib, json, os, re, subprocess, sys, time, urllib.request, urllib.error, wave + +BASEDIR = '/opt/data/hn-oss-digest' +C = json.load(open(BASEDIR + '/creds.json')) +SRC = sys.argv[1] if len(sys.argv) > 1 else BASEDIR + '/narration.md' +OUT = sys.argv[2] if len(sys.argv) > 2 else BASEDIR + '/digest-audio.mp3' +CACHE = os.path.join(BASEDIR, 'tts-cache') +os.makedirs(CACHE, exist_ok=True) +STYLE = ('Speak in Italian with a native Italian accent, warm and passionate like a tech ' + 'podcast narrator, conversational pace, natural pauses between sentences, a hint ' + 'of wonder on curious details, friendly closing energy: \n') +PAUSE_BETWEEN = 12 # secondi tra una chiamata TTS e l'altra + +def segments(text, maxlen=1500): + paras = [p.strip() for p in re.split(r'\n\s*\n', text) if p.strip()] + segs, cur = [], '' + for p in paras: + if len(cur) + len(p) + 2 <= maxlen: + cur = (cur + '\n\n' + p).strip() + else: + if cur: + segs.append(cur) + while len(p) > maxlen: + cut = p.rfind('. ', 0, maxlen) + cut = cut + 1 if cut > 200 else maxlen + segs.append(p[:cut].strip()) + p = p[cut:].strip() + cur = p + if cur: + segs.append(cur) + return segs + +MODELS = ['gemini-3.8-flash-lite-tts', 'gemini-3.8-flash-tts', + 'gemini-3.1-flash-tts-preview', 'gemini-2.5-flash-preview-tts'] + +def tts_once(text, model): + payload = { + 'contents': [{'parts': [{'text': STYLE + text}]}], + 'generationConfig': { + 'responseModalities': ['AUDIO'], + 'speechConfig': {'voiceConfig': {'prebuiltVoiceConfig': {'voiceName': 'Kore'}}}, + }, + } + url = ('https://generativelanguage.googleapis.com/v1beta/models/' + model + ':generateContent?key=' + + C['gemini_key']) + req = urllib.request.Request(url, data=json.dumps(payload).encode(), + headers={'Content-Type': 'application/json'}) + with urllib.request.urlopen(req, timeout=300) as r: + x = json.loads(r.read().decode()) + cand = x['candidates'][0] + part = cand['content']['parts'][0] + data = base64.b64decode(part['inlineData']['data']) + rate = 24000 + for tok in part['inlineData'].get('mimeType', '').split(';'): + if tok.strip().startswith('rate='): + rate = int(tok.split('=')[1]) + if cand.get('finishReason') != 'STOP': + raise RuntimeError('finishReason=' + str(cand.get('finishReason'))) + return data, rate + +def tts(text): + """Cache + cascata di modelli + backoff: se un modello e' a secco (429) prova il prossimo.""" + key = hashlib.sha256((STYLE + text).encode()).hexdigest()[:16] + cache_file = os.path.join(CACHE, key + '.json') + if os.path.exists(cache_file): + d = json.load(open(cache_file)) + print(' [cache]', end=' ') + return base64.b64decode(d['data']), d['rate'] + models = [m for m in MODELS] + delay = 60 + for cycle in range(3): + for model in list(models): + try: + data, rate = tts_once(text, model) + json.dump({'data': base64.b64encode(data).decode(), 'rate': rate}, open(cache_file, 'w')) + return data, rate + except urllib.error.HTTPError as e: + if e.code == 429: + print(f' [{model.split("-tts")[0]}: esaurito]', end=' ', flush=True) + continue + if e.code in (500, 503): + print(f' [{model}: {e.code}, riprovo]', end=' ', flush=True) + time.sleep(30) + continue + raise + if cycle < 2: + print(f' [tutti a secco, attendo {delay}s]', end=' ', flush=True) + time.sleep(delay) + delay *= 3 + raise RuntimeError('tts: tutti i modelli esauriti') + +text = open(SRC, encoding='utf-8').read() +segs = segments(text) +print(f'narrazione: {len(text)} char -> {len(segs)} segmenti audio') + +frames, rate = [], 24000 +for i, s in enumerate(segs, 1): + data, r = tts(s) + dur = len(data) / (r * 2) + atteso = max(6.0, len(s) / 14.0) + if dur > atteso * 1.8: + print(f' segmento {i}: {dur:.0f}s vs attesi ~{atteso:.0f}s -> retry (senza cache)') + os.remove(os.path.join(CACHE, hashlib.sha256((STYLE + s).encode()).hexdigest()[:16] + '.json')) + data, r = tts(s) + dur = len(data) / (r * 2) + if dur > atteso * 1.8: + print(f' segmento {i}: ancora {dur:.0f}s, lo tengo (possibile divagazione)') + rate = r + frames.append(data) + print(f' segmento {i}/{len(segs)}: {dur:.1f}s') + if i < len(segs): + time.sleep(PAUSE_BETWEEN) + +wav = OUT.replace('.mp3', '.wav') +with wave.open(wav, 'wb') as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(rate) + for f in frames: + w.writeframes(f) + +subprocess.run(['ffmpeg', '-y', '-loglevel', 'error', '-i', wav, + '-codec:a', 'libmp3lame', '-b:a', '64k', '-ar', '24000', '-ac', '1', OUT], + check=True) +print('OK:', OUT, os.path.getsize(OUT) // 1024, 'KB')