first commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
*secret.yaml
|
||||||
|
.claude
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
# AGENTS.md — Tech Watch Automation Stack
|
||||||
|
|
||||||
|
Instructions for implementing an automated technology-watch (veille) pipeline on Kubernetes.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Deploy a self-hosted stack that:
|
||||||
|
1. Aggregates tech news sources (RSS and APIs) via **Miniflux**
|
||||||
|
2. Orchestrates workflows via **n8n** (fetch unread articles, deduplicate, summarize, deliver digest)
|
||||||
|
3. Uses an **LLM through an OpenAI-compatible API** (OpenRouter today, possibly a local LLM proxy later — never hardcode OpenRouter specifics)
|
||||||
|
4. Produces a **weekly (and optionally daily) digest**, prioritized by topic, delivered by email and/or saved as Markdown
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
[RSS/API sources] --> [Miniflux] --(REST API)--> [n8n workflows] --> [LLM (OpenAI-compatible)] --> [Digest: email / file / webhook]
|
||||||
|
| |
|
||||||
|
[PostgreSQL] <------------------+ (n8n also uses Postgres)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stack & Constraints
|
||||||
|
|
||||||
|
- **Kubernetes** deployment (target: any conformant cluster). Prefer **Helm charts** where official/community charts exist; otherwise write plain manifests (Kustomize layout welcome).
|
||||||
|
- **Miniflux**: official container image `miniflux/miniflux`. Needs PostgreSQL. Enable API access. Run DB migrations via env `RUN_MIGRATIONS=1`.
|
||||||
|
- **PostgreSQL**: one instance (or one per app if simpler), e.g. Bitnami chart or CloudNativePG. Persistent volumes required.
|
||||||
|
- **n8n**: official image `n8nio/n8n`. Configure with Postgres backend (not SQLite). Persist `/home/node/.n8n`.
|
||||||
|
- **LLM access**: strictly through the **OpenAI-compatible chat completions interface** (`/v1/chat/completions`). Configuration via env vars only:
|
||||||
|
- `LLM_BASE_URL` (e.g. `https://openrouter.ai/api/v1`, later a local proxy URL)
|
||||||
|
- `LLM_API_KEY`
|
||||||
|
- `LLM_MODEL` (model identifier string, treat as opaque)
|
||||||
|
- Do not use vendor-specific SDK features; plain HTTP calls or the generic OpenAI node in n8n with a custom base URL.
|
||||||
|
- **Secrets**: Kubernetes Secrets (no secrets in git). Provide `*.example` files.
|
||||||
|
- **Ingress**: expose Miniflux and n8n UIs behind an Ingress (TLS assumed handled by cluster / cert-manager). Make hostnames configurable.
|
||||||
|
- **Code and comments in English.**
|
||||||
|
|
||||||
|
## Implementation Tasks
|
||||||
|
|
||||||
|
1. **Manifests/Charts**
|
||||||
|
- Namespace `veille` (configurable)
|
||||||
|
- PostgreSQL with PVC, credentials in Secret
|
||||||
|
- Miniflux Deployment + Service + Ingress; admin credentials in Secret; `DATABASE_URL` from Secret
|
||||||
|
- n8n Deployment + Service + Ingress; `DB_TYPE=postgresdb` config; encryption key in Secret
|
||||||
|
- NetworkPolicies (optional, nice to have): only n8n → Miniflux, apps → Postgres
|
||||||
|
2. **Miniflux bootstrap**
|
||||||
|
- Script or Job that creates the feed list (see Sources below) via the Miniflux REST API (`POST /v1/feeds`), organized in categories matching the topics
|
||||||
|
- Create an API token for n8n
|
||||||
|
3. **n8n workflows** (export as JSON in repo, `workflows/`)
|
||||||
|
- **Digest workflow** (cron, weekly Friday 07:00 Europe/Paris; optional daily variant):
|
||||||
|
1. `GET /v1/entries?status=unread&limit=...` from Miniflux
|
||||||
|
2. Group entries by category/topic
|
||||||
|
3. For each topic: call LLM to summarize + rank top items (relevance to the topics listed below); one LLM call per topic to control context size
|
||||||
|
4. Compose final digest (Markdown): per topic — 3–5 top items with 1–2 sentence summaries + links, then a short "weak signals" section
|
||||||
|
5. Deliver: send email (SMTP node) and/or commit/save Markdown; then mark entries as read (`PUT /v1/entries` status=read)
|
||||||
|
- **Error handling**: retries on HTTP calls, dead-letter notification on failure
|
||||||
|
4. **Docs**
|
||||||
|
- `README.md`: deployment steps, secret setup, how to add a source, how to change LLM endpoint
|
||||||
|
5. **Validation**
|
||||||
|
- Lint manifests (`kubectl apply --dry-run=client` or `kubeconform`)
|
||||||
|
- Provide a smoke-test checklist
|
||||||
|
|
||||||
|
## Digest Priorities (for LLM prompt)
|
||||||
|
|
||||||
|
Rank content by relevance to, in order:
|
||||||
|
1. Application & data integration (iPaaS, ESB, API management)
|
||||||
|
2. Event-driven architecture, messaging, streaming (Kafka, AMQP, MQTT, AsyncAPI, CloudEvents)
|
||||||
|
3. Agentic AI, LLM integration patterns, MCP (Model Context Protocol)
|
||||||
|
4. Cloud native / Kubernetes / platform engineering
|
||||||
|
5. MFT (Managed File Transfer) and B2B/EDI integration
|
||||||
|
6. General cloud computing announcements (only if significant)
|
||||||
|
|
||||||
|
## Sources to Configure in Miniflux
|
||||||
|
|
||||||
|
All sources below are RSS/Atom unless stated otherwise. If a feed URL is broken at implementation time, locate the current one (feeds move); as a fallback use an HTML-to-RSS bridge (e.g. RSSHub) or n8n HTTP scraping.
|
||||||
|
|
||||||
|
### Cloud Native / Kubernetes
|
||||||
|
| Source | Access | URL |
|
||||||
|
|---|---|---|
|
||||||
|
| CNCF blog | RSS | https://www.cncf.io/feed/ |
|
||||||
|
| Kubernetes blog | RSS | https://kubernetes.io/feed.xml |
|
||||||
|
| The New Stack | RSS | https://thenewstack.io/feed/ |
|
||||||
|
| InfoQ – Cloud | RSS | https://feed.infoq.com/cloud-computing/ |
|
||||||
|
| InfoQ – DevOps | RSS | https://feed.infoq.com/devops/ |
|
||||||
|
|
||||||
|
### Cloud Providers
|
||||||
|
| Source | Access | URL |
|
||||||
|
|---|---|---|
|
||||||
|
| AWS News Blog | RSS | https://aws.amazon.com/blogs/aws/feed/ |
|
||||||
|
| AWS Compute/Integration blogs | RSS | discover per-category feeds under aws.amazon.com/blogs/ |
|
||||||
|
| Azure updates | RSS | https://azurecomm.azure.com/updates/feed/ (verify; fallback: Azure blog https://azure.microsoft.com/en-us/blog/feed/) |
|
||||||
|
| Google Cloud blog | RSS | https://cloudblog.withgoogle.com/rss/ |
|
||||||
|
|
||||||
|
### Integration / iPaaS / API
|
||||||
|
| Source | Access | URL |
|
||||||
|
|---|---|---|
|
||||||
|
| MuleSoft blog | RSS | https://blogs.mulesoft.com/feed/ |
|
||||||
|
| Boomi blog | RSS | https://boomi.com/blog/feed/ (verify) |
|
||||||
|
| Kong blog | RSS | https://konghq.com/blog/rss.xml (verify) |
|
||||||
|
| Apache Camel blog | RSS | https://camel.apache.org/feed.xml (verify) |
|
||||||
|
| WSO2 blog | RSS | https://wso2.com/feed/ (verify) |
|
||||||
|
|
||||||
|
### EDA / Messaging / Streaming
|
||||||
|
| Source | Access | URL |
|
||||||
|
|---|---|---|
|
||||||
|
| Confluent blog | RSS | https://www.confluent.io/blog/feed/ (verify) |
|
||||||
|
| Kai Waehner | RSS | https://www.kai-waehner.de/blog/feed/ |
|
||||||
|
| Solace blog | RSS | https://solace.com/feed/ |
|
||||||
|
| AsyncAPI blog | RSS | https://www.asyncapi.com/rss.xml (verify) |
|
||||||
|
| RabbitMQ blog | RSS | https://www.rabbitmq.com/blog/rss.xml (verify) |
|
||||||
|
| Apache Kafka releases | Atom (GitHub) | https://github.com/apache/kafka/releases.atom |
|
||||||
|
|
||||||
|
### Agentic AI / LLM
|
||||||
|
| Source | Access | URL |
|
||||||
|
|---|---|---|
|
||||||
|
| Anthropic news | RSS | https://www.anthropic.com/rss.xml (verify; fallback RSSHub) |
|
||||||
|
| OpenAI blog | RSS | https://openai.com/blog/rss.xml (verify) |
|
||||||
|
| LangChain blog | RSS | https://blog.langchain.dev/rss/ |
|
||||||
|
| Simon Willison | RSS | https://simonwillison.net/atom/everything/ |
|
||||||
|
| Latent Space | RSS | https://www.latent.space/feed |
|
||||||
|
| MCP spec repo releases | Atom (GitHub) | https://github.com/modelcontextprotocol/modelcontextprotocol/releases.atom (verify repo path) |
|
||||||
|
|
||||||
|
### MFT / B2B / EDI
|
||||||
|
| Source | Access | URL |
|
||||||
|
|---|---|---|
|
||||||
|
| Axway blog | RSS | https://blog.axway.com/feed (verify) |
|
||||||
|
| Cleo blog | RSS | https://www.cleo.com/blog/rss.xml (verify) |
|
||||||
|
| SEEBURGER blog | RSS | https://blog.seeburger.com/feed/ (verify) |
|
||||||
|
| IBM Sterling / integration topics | RSS or scrape | discover on ibm.com/blog; fallback RSSHub |
|
||||||
|
|
||||||
|
### Cross-cutting / Signal
|
||||||
|
| Source | Access | URL |
|
||||||
|
|---|---|---|
|
||||||
|
| Hacker News (front page) | RSS | https://news.ycombinator.com/rss |
|
||||||
|
| HN best (less noise) | RSS | https://hnrss.org/best |
|
||||||
|
| ThoughtWorks Technology Radar | RSS | https://www.thoughtworks.com/rss/insights.xml (verify) — biannual, high value |
|
||||||
|
| Reddit r/devops | RSS | https://www.reddit.com/r/devops/.rss |
|
||||||
|
| Reddit r/kubernetes | RSS | https://www.reddit.com/r/kubernetes/.rss |
|
||||||
|
| Reddit r/dataengineering | RSS | https://www.reddit.com/r/dataengineering/.rss |
|
||||||
|
|
||||||
|
### Security / Vulnerabilities
|
||||||
|
| Source | Access | URL |
|
||||||
|
|---|---|---|
|
||||||
|
| CISA Known Exploited Vulnerabilities | RSS/JSON | https://www.cisa.gov/cybersecurity-advisories/all.xml (advisories); KEV catalog JSON: https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json (poll via n8n) |
|
||||||
|
| Kubernetes security announces | Google Group RSS | https://groups.google.com/g/kubernetes-security-announce (verify RSS availability; fallback: kubernetes.io blog security tag) |
|
||||||
|
| GitHub Security Advisories | Atom per ecosystem/repo | https://github.com/advisories (filterable; per-repo: `https://github.com/{org}/{repo}/security/advisories` — poll via n8n/API) |
|
||||||
|
| The Hacker News | RSS | https://feeds.feedburner.com/TheHackersNews |
|
||||||
|
| Bleeping Computer | RSS | https://www.bleepingcomputer.com/feed/ |
|
||||||
|
| NVD CVE feed | JSON API | https://services.nvd.nist.gov/rest/json/cves/2.0 — poll via n8n with keyword filters (kafka, kubernetes, n8n, miniflux, postgresql…); too noisy for raw RSS |
|
||||||
|
|
||||||
|
Digest handling: security items form a dedicated "Security" section in the digest, filtered by relevance to the deployed/covered stack (Kubernetes, Kafka, PostgreSQL, n8n, integration middleware). Critical/KEV items may trigger an immediate notification instead of waiting for the weekly digest.
|
||||||
|
|
||||||
|
### Non-RSS sources (handled in n8n, not Miniflux)
|
||||||
|
- **GitHub releases** for key projects: use `https://github.com/{org}/{repo}/releases.atom` (works in Miniflux) — add repos as needed (kubernetes, istio, keda, camel, kafka, langchain, n8n…)
|
||||||
|
- **Google Alerts**: create alerts ("MFT managed file transfer", "EDI modernization", "iPaaS"), deliver as RSS, add to Miniflux
|
||||||
|
- **LinkedIn / Gartner summaries**: no reliable API; keep manual
|
||||||
|
|
||||||
|
### Market Trends / Business (M&A, funding, analyst views)
|
||||||
|
| Source | Access | URL |
|
||||||
|
|---|---|---|
|
||||||
|
| TechCrunch – Enterprise | RSS | https://techcrunch.com/category/enterprise/feed/ |
|
||||||
|
| The Register | RSS | https://www.theregister.com/headlines.atom |
|
||||||
|
| SiliconANGLE | RSS | https://siliconangle.com/feed/ (enterprise/cloud coverage, theCUBE analysts) |
|
||||||
|
| Futurum Group | RSS | https://futurumgroup.com/feed/ (verify) — accessible analyst research |
|
||||||
|
| Constellation Research blog | RSS | https://www.constellationr.com/rss.xml (verify) |
|
||||||
|
| CNCF Annual Survey & reports | Scrape/manual | https://www.cncf.io/reports/ — annual, high value |
|
||||||
|
| DB-Engines ranking | Scrape/manual | https://db-engines.com/en/ranking — monthly trend check via n8n |
|
||||||
|
| Gartner / Forrester | Manual | paywalled; capture via vendors' "named leader" announcements (already covered by vendor blog feeds) and analyst LinkedIn posts |
|
||||||
|
|
||||||
|
Annual reports worth flagging in the digest when released: CNCF Survey, Stack Overflow Developer Survey, DORA State of DevOps, ThoughtWorks Tech Radar (biannual), State of the API (Postman).
|
||||||
|
|
||||||
|
Digest handling: market items go in a dedicated "Market & Trends" section — acquisitions, funding rounds, major partnerships, analyst rankings — restricted to the integration/EDA/cloud/AI space. Example of relevant signal: the IBM acquisition of Confluent ($11B, 2026).
|
||||||
|
|
||||||
|
### People to Follow
|
||||||
|
For each person: prefer their **personal blog RSS** if one exists (check first); otherwise create a **Google Alert** on their quoted name, delivered as RSS, and add it to Miniflux in a "People" category.
|
||||||
|
|
||||||
|
**Integration / iPaaS / API**
|
||||||
|
- Kai Waehner — blog RSS exists (already listed above); independent since leaving Confluent (2026)
|
||||||
|
- Sanjeev Mohan (data & analytics, ex-Gartner)
|
||||||
|
- Massimo Pezzini (ex-Gartner, iPaaS/HIP reference)
|
||||||
|
- Saurabh Sharma (integration analyst)
|
||||||
|
- Holger Mueller (Constellation Research)
|
||||||
|
|
||||||
|
**EDA / Messaging / Streaming**
|
||||||
|
- Gwen Shapira (Kafka)
|
||||||
|
- Adam Bellemare (event-driven microservices)
|
||||||
|
- Hugo Guerrero (API/messaging, Red Hat)
|
||||||
|
- Clemens Vasters (Microsoft, messaging/CloudEvents)
|
||||||
|
|
||||||
|
**Agentic AI / LLM**
|
||||||
|
- Simon Willison — blog RSS exists (already listed above)
|
||||||
|
- Andrew Ng
|
||||||
|
- Harrison Chase (LangChain)
|
||||||
|
- swyx / Shawn Wang (Latent Space — RSS already listed)
|
||||||
|
- Chip Huyen (ML systems)
|
||||||
|
|
||||||
|
**Cloud native / Platform engineering**
|
||||||
|
- Kelsey Hightower
|
||||||
|
- Viktor Farcic (DevOps Toolkit)
|
||||||
|
- Sam Newman (microservices)
|
||||||
|
- Charity Majors (observability)
|
||||||
|
|
||||||
|
**Architecture**
|
||||||
|
- Gregor Hohpe (Enterprise Integration Patterns) — check for blog RSS (architectelevator.com)
|
||||||
|
- Neal Ford (ThoughtWorks)
|
||||||
|
- Mark Richards (developertoasarchitect.com)
|
||||||
|
|
||||||
|
**MFT / B2B / EDI**: no strong public figures; the iPaaS analysts above (Pezzini, Sharma) also cover B2B.
|
||||||
|
|
||||||
|
|
||||||
|
## Notes for the Agent
|
||||||
|
|
||||||
|
- **Truncated feeds**: many feeds (media, vendor blogs) only carry title + excerpt, not full content. Strategy:
|
||||||
|
1. Enable Miniflux **"Fetch original content"** (scraper) per feed for truncated sources — set `crawler: true` when creating feeds via the API (test each feed; disable if the site blocks scraping).
|
||||||
|
2. In the n8n digest workflow, treat `content` defensively: if the entry content is below ~500 characters, either summarize from title + excerpt only, or fetch the article URL directly (HTTP node) as fallback. Never assume full text is available.
|
||||||
|
- Verify every feed URL with an HTTP GET before adding it; log and replace dead feeds.
|
||||||
|
- Keep the LLM prompt for summarization in a dedicated file (`prompts/digest.md`) so it can be tuned without touching workflows.
|
||||||
|
- Digest language: **French** output for the digest content; everything else (code, comments, docs) in English.
|
||||||
|
- Keep resource requests modest (this is a personal stack): e.g. 256Mi/0.1 CPU requests per app, adjust limits sensibly.
|
||||||
|
- Timezone: Europe/Paris for all cron schedules.
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
# Tech Watch Automation Stack — Miniflux + n8n + digest workflow
|
||||||
|
|
||||||
|
This covers the full stack described in `AGENTS.md` (namespace `watch`,
|
||||||
|
shared PostgreSQL in namespace `postgres`, Miniflux with the initial feed
|
||||||
|
bootstrap, n8n for workflow orchestration, and the daily LLM digest
|
||||||
|
workflow) plus a small read-only file server for the generated digests.
|
||||||
|
Everything below is deployed and active on this cluster.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
k8s/
|
||||||
|
namespace.yaml # namespace: watch (apps)
|
||||||
|
postgres/ # shared PostgreSQL instance (namespace: postgres)
|
||||||
|
namespace.yaml
|
||||||
|
secret.example.yaml # superuser credentials
|
||||||
|
statefulset.yaml
|
||||||
|
service.yaml
|
||||||
|
miniflux/ # namespace: watch (except create-db-job.yaml)
|
||||||
|
secret.example.yaml # DATABASE_URL, admin credentials
|
||||||
|
deployment.yaml
|
||||||
|
service.yaml
|
||||||
|
gateway.yaml # Gateway API (Envoy), HTTPS listener on 443
|
||||||
|
httproute.yaml # routes miniflux.sttlab.pc to the Service
|
||||||
|
create-db-job.yaml # runs in ns postgres: creates the "miniflux" database
|
||||||
|
bootstrap-feeds-job.yaml # runs miniflux-initializer to create categories/feeds
|
||||||
|
n8n/ # namespace: watch (except create-db-job.yaml)
|
||||||
|
secret.example.yaml # DB credentials, N8N_ENCRYPTION_KEY
|
||||||
|
miniflux-token-secret.example.yaml # Miniflux API token, backup/reference for the n8n credential
|
||||||
|
pvc.yaml # n8n-data (n8n's own /home/node/.n8n) + n8n-digest-workspace (/data, for Read/Write File nodes)
|
||||||
|
deployment.yaml
|
||||||
|
service.yaml
|
||||||
|
gateway.yaml # Gateway API (Envoy), HTTPS listener on 443
|
||||||
|
httproute.yaml # routes n8n.sttlab.pc to the Service
|
||||||
|
create-db-job.yaml # runs in ns postgres: creates the "n8n" database
|
||||||
|
digest-files/ # namespace: watch — read-only nginx file server
|
||||||
|
configmap.yaml # nginx.conf: autoindex on
|
||||||
|
deployment.yaml # mounts n8n-digest-workspace PVC (digests/ subdir), read-only
|
||||||
|
service.yaml
|
||||||
|
gateway.yaml # routes digests.sttlab.pc
|
||||||
|
httproute.yaml
|
||||||
|
miniflux-initializer/ # container image used by bootstrap-feeds-job.yaml
|
||||||
|
Dockerfile
|
||||||
|
bootstrap_feeds.py
|
||||||
|
feeds.yaml
|
||||||
|
requirements.txt
|
||||||
|
prompts/
|
||||||
|
digest.md # LLM system prompt for the digest workflow; mounted into n8n via
|
||||||
|
# the n8n-digest-prompt ConfigMap (regenerate after editing, see below)
|
||||||
|
workflows/
|
||||||
|
digest.json # "Tech Watch Digest" n8n workflow (daily 06:00 Europe/Paris cron)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
1. **Namespaces**
|
||||||
|
```
|
||||||
|
kubectl apply -f k8s/namespace.yaml
|
||||||
|
kubectl apply -f k8s/postgres/namespace.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **PostgreSQL** — `k8s/postgres/{namespace,secret.example,statefulset,service}.yaml`
|
||||||
|
deploy a standalone instance and are kept for reference/portability, but
|
||||||
|
**on this cluster a shared PostgreSQL already runs** in the `postgres`
|
||||||
|
namespace (Bitnami Helm chart, release `postgres`, service
|
||||||
|
`postgres-postgresql`). Reuse it instead of applying the StatefulSet:
|
||||||
|
```
|
||||||
|
cp k8s/postgres/secret.example.yaml k8s/postgres/secret.yaml
|
||||||
|
# edit k8s/postgres/secret.yaml: POSTGRES_PASSWORD must match the real
|
||||||
|
# instance's password:
|
||||||
|
# kubectl get secret postgres-postgresql -n postgres -o jsonpath='{.data.postgres-password}' | base64 -d
|
||||||
|
kubectl apply -f k8s/postgres/secret.yaml
|
||||||
|
kubectl apply -f k8s/miniflux/create-db-job.yaml
|
||||||
|
kubectl wait --for=condition=complete job/create-db-miniflux -n postgres --timeout=60s
|
||||||
|
```
|
||||||
|
(If deploying to a cluster with no existing shared Postgres, apply
|
||||||
|
`statefulset.yaml`/`service.yaml` first and update
|
||||||
|
`create-db-job.yaml`'s host from `postgres-postgresql` to `postgres`.)
|
||||||
|
|
||||||
|
3. **TLS secret** for the Gateway listeners (using the existing wildcard cert for `*.sttlab.pc`)
|
||||||
|
```
|
||||||
|
kubectl create secret tls sttlab-pc-tls \
|
||||||
|
--cert=~/tls/sttlab.pc.crt --key=~/tls/sttlab.pc.key \
|
||||||
|
-n watch
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Miniflux**
|
||||||
|
```
|
||||||
|
cp k8s/miniflux/secret.example.yaml k8s/miniflux/secret.yaml
|
||||||
|
# edit k8s/miniflux/secret.yaml:
|
||||||
|
# - DATABASE_URL password must match k8s/postgres/secret.yaml
|
||||||
|
# - set ADMIN_USERNAME / ADMIN_PASSWORD for the Miniflux admin account
|
||||||
|
kubectl apply -f k8s/miniflux/secret.yaml
|
||||||
|
kubectl apply -f k8s/miniflux/deployment.yaml
|
||||||
|
kubectl apply -f k8s/miniflux/service.yaml
|
||||||
|
kubectl apply -f k8s/miniflux/gateway.yaml
|
||||||
|
kubectl apply -f k8s/miniflux/httproute.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Exposed via Gateway API (Envoy Gateway, `gatewayClassName: envoy`) on
|
||||||
|
port 443 — this cluster's `ingress-nginx` only exposes ports 80/9443
|
||||||
|
externally, not 443, so a plain `Ingress` would not actually be
|
||||||
|
reachable over HTTPS. Point DNS for `miniflux.sttlab.pc` at the Gateway
|
||||||
|
address if not already done (`kubectl get gateway miniflux -n watch`).
|
||||||
|
|
||||||
|
Feed `crawler` (fetch original article content instead of the raw
|
||||||
|
RSS/Atom excerpt) is enabled on all feeds except OpenAI's — its blog is
|
||||||
|
behind a Cloudflare bot challenge that always rejects Miniflux's
|
||||||
|
fetcher, so it stays on the RSS excerpt. See `crawler:` per feed in
|
||||||
|
`miniflux-initializer/feeds.yaml`.
|
||||||
|
|
||||||
|
5. **Bootstrap feeds** — runs as a one-off Job using a locally built image
|
||||||
|
(no external registry needed on a single-node k3s cluster):
|
||||||
|
```
|
||||||
|
cd miniflux-initializer
|
||||||
|
docker build --network=host -t miniflux-initializer:latest .
|
||||||
|
docker save miniflux-initializer:latest | sudo k3s ctr images import -
|
||||||
|
cd ..
|
||||||
|
kubectl apply -f k8s/miniflux/bootstrap-feeds-job.yaml
|
||||||
|
kubectl wait --for=condition=complete job/bootstrap-feeds -n watch --timeout=60s
|
||||||
|
kubectl logs -n watch job/bootstrap-feeds
|
||||||
|
```
|
||||||
|
Safe to re-run: delete the Job (`kubectl delete job bootstrap-feeds -n
|
||||||
|
watch`) and re-apply after editing `miniflux-initializer/feeds.yaml`
|
||||||
|
(rebuild + re-import the image first if you changed it). Only adds new
|
||||||
|
feeds — it does not update `crawler`/settings on feeds that already
|
||||||
|
exist (use the Miniflux API or UI for that).
|
||||||
|
|
||||||
|
> Note: `--network=host` is required for the `pip install` step during
|
||||||
|
> the image build — the local router does not answer DNS queries from
|
||||||
|
> Docker's bridge subnet, only the host's own resolver works.
|
||||||
|
|
||||||
|
6. **API token for n8n**: create it manually in the Miniflux UI under
|
||||||
|
*Settings → API Keys* — there is no public REST endpoint to create one.
|
||||||
|
Kept as the source of truth in `k8s/n8n/miniflux-token-secret.yaml` (see
|
||||||
|
`.example` for the format); the n8n credential itself is created in step
|
||||||
|
8 below, from the same token.
|
||||||
|
|
||||||
|
7. **n8n**
|
||||||
|
```
|
||||||
|
cp k8s/n8n/secret.example.yaml k8s/n8n/secret.yaml
|
||||||
|
# edit k8s/n8n/secret.yaml:
|
||||||
|
# - DB_POSTGRESDB_PASSWORD must match k8s/postgres/secret.yaml
|
||||||
|
# - N8N_ENCRYPTION_KEY: generate once with `openssl rand -hex 24` and
|
||||||
|
# never rotate it afterwards (it decrypts every saved credential)
|
||||||
|
kubectl apply -f k8s/n8n/secret.yaml
|
||||||
|
kubectl apply -f k8s/n8n/create-db-job.yaml
|
||||||
|
kubectl wait --for=condition=complete job/create-db-n8n -n postgres --timeout=60s
|
||||||
|
kubectl apply -f k8s/n8n/pvc.yaml
|
||||||
|
# ConfigMap holding the digest LLM prompt (prompts/digest.md is the source
|
||||||
|
# of truth; regenerate and re-apply this any time the file changes):
|
||||||
|
kubectl create configmap n8n-digest-prompt --from-file=digest.md=prompts/digest.md \
|
||||||
|
-n watch --dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
kubectl apply -f k8s/n8n/deployment.yaml
|
||||||
|
kubectl apply -f k8s/n8n/service.yaml
|
||||||
|
kubectl apply -f k8s/n8n/gateway.yaml
|
||||||
|
kubectl apply -f k8s/n8n/httproute.yaml
|
||||||
|
```
|
||||||
|
Same Gateway API setup as Miniflux (TLS terminated by Envoy Gateway on
|
||||||
|
443, reusing the `sttlab-pc-tls` secret). Point DNS for `n8n.sttlab.pc`
|
||||||
|
at the Gateway address once (`kubectl get gateway n8n -n watch`).
|
||||||
|
|
||||||
|
On first login, n8n prompts to create the owner account (email/password)
|
||||||
|
— there's no `CREATE_ADMIN`-style env var like Miniflux, so this step is
|
||||||
|
manual in the UI.
|
||||||
|
|
||||||
|
Two PVCs are mounted: `n8n-data` is n8n's own `/home/node/.n8n` (config,
|
||||||
|
binary data cache); `n8n-digest-workspace` is mounted separately at
|
||||||
|
`/data` for the digest workflow's Read/Write File nodes (prompt +
|
||||||
|
generated digests). They're kept apart because n8n unconditionally
|
||||||
|
blocks file-node access to its own `/home/node/.n8n` directory
|
||||||
|
(`N8N_BLOCK_FILE_ACCESS_TO_N8N_FILES`, not overridable via
|
||||||
|
`N8N_RESTRICT_FILE_ACCESS_TO`) — a dedicated volume avoids that
|
||||||
|
restriction honestly instead of working around it. Note also that
|
||||||
|
`N8N_RESTRICT_FILE_ACCESS_TO` only accepts a single path, not a list —
|
||||||
|
hence both `prompts/` and `digests/` live under the same `/data` root.
|
||||||
|
|
||||||
|
8. **Digest workflow** — import `workflows/digest.json` (the "Tech Watch
|
||||||
|
Digest" workflow: daily 06:00 Europe/Paris cron) and wire its
|
||||||
|
credentials via the n8n Public API (Settings → n8n API → create a key
|
||||||
|
first):
|
||||||
|
```
|
||||||
|
N8N_KEY=<your n8n API key>
|
||||||
|
|
||||||
|
# Miniflux credential (Header Auth, X-Auth-Token) — use the same token
|
||||||
|
# as k8s/n8n/miniflux-token-secret.yaml
|
||||||
|
curl -s https://n8n.sttlab.pc/api/v1/credentials -X POST \
|
||||||
|
-H "X-N8N-API-KEY: $N8N_KEY" -H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"Miniflux API","type":"httpHeaderAuth","data":{"name":"X-Auth-Token","value":"<token>","allowedHttpRequestDomains":"all"}}'
|
||||||
|
|
||||||
|
# LLM credential (Header Auth, Authorization: Bearer <key>) — OpenRouter or
|
||||||
|
# any OpenAI-compatible provider
|
||||||
|
curl -s https://n8n.sttlab.pc/api/v1/credentials -X POST \
|
||||||
|
-H "X-N8N-API-KEY: $N8N_KEY" -H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"LLM API","type":"httpHeaderAuth","data":{"name":"Authorization","value":"Bearer <key>","allowedHttpRequestDomains":"all"}}'
|
||||||
|
|
||||||
|
# Import the workflow, then edit the two credential IDs in its HTTP
|
||||||
|
# Request nodes ("Get Unread Entries" / "Mark Entries As Read" -> Miniflux
|
||||||
|
# API; "Summarize Category" -> LLM API) to match the IDs returned above,
|
||||||
|
# and set the real llmBaseUrl / llmModel in the "Init" node's code.
|
||||||
|
curl -s https://n8n.sttlab.pc/api/v1/workflows -X POST \
|
||||||
|
-H "X-N8N-API-KEY: $N8N_KEY" -H "Content-Type: application/json" \
|
||||||
|
--data-binary @<(python3 -c "import json; wf=json.load(open('workflows/digest.json')); print(json.dumps({k: wf[k] for k in ('name','nodes','connections','settings')}))")
|
||||||
|
|
||||||
|
# Activate once credentials are wired and a manual run succeeds:
|
||||||
|
curl -s https://n8n.sttlab.pc/api/v1/workflows/<id>/activate -X POST \
|
||||||
|
-H "X-N8N-API-KEY: $N8N_KEY"
|
||||||
|
```
|
||||||
|
The workflow uses Miniflux's **internal** cluster Service DNS
|
||||||
|
(`http://miniflux.watch.svc.cluster.local/v1/entries`), not the external
|
||||||
|
HTTPS hostname — `miniflux.sttlab.pc` isn't resolvable from inside the
|
||||||
|
cluster (it only exists as a Gateway route, no DNS record).
|
||||||
|
|
||||||
|
To test-run before activating (the running Deployment pod can't run
|
||||||
|
`n8n execute` itself — its Task Broker port is already bound — so use a
|
||||||
|
throwaway Job with the same image/env/volumes instead), or just use the
|
||||||
|
"Test workflow" button in the n8n UI from the Schedule Trigger node.
|
||||||
|
|
||||||
|
Pipeline: fetch unread Miniflux entries from the last 24h → group by
|
||||||
|
category → for each category, call the LLM (system prompt from
|
||||||
|
`prompts/digest.md`) to pick the 3-5 most relevant items + weak signals,
|
||||||
|
strict JSON output → compose one Markdown file → write it to
|
||||||
|
`/data/digests/digest-<date>.md` → mark the processed entries as read in
|
||||||
|
Miniflux.
|
||||||
|
|
||||||
|
9. **Digest file server** — serves `/data/digests/` (read-only) so digests
|
||||||
|
can be browsed/downloaded from a browser instead of email:
|
||||||
|
```
|
||||||
|
kubectl apply -f k8s/digest-files/
|
||||||
|
```
|
||||||
|
Same Gateway/TLS pattern, routes `digests.sttlab.pc`. Point DNS at the
|
||||||
|
Gateway address (`kubectl get gateway digest-files -n watch`).
|
||||||
|
|
||||||
|
## How to add a source
|
||||||
|
|
||||||
|
Add an entry under the relevant category (or a new category) in
|
||||||
|
`miniflux-initializer/feeds.yaml`, verify the URL responds with `curl -I
|
||||||
|
<url>`, then rebuild the image and re-run the bootstrap Job (see step 5).
|
||||||
|
|
||||||
|
## Validate manifests
|
||||||
|
|
||||||
|
```
|
||||||
|
kubectl apply --dry-run=client -f k8s/namespace.yaml
|
||||||
|
kubectl apply --dry-run=client -f k8s/postgres/
|
||||||
|
kubectl apply --dry-run=client -f k8s/miniflux/
|
||||||
|
kubectl apply --dry-run=client -f k8s/n8n/
|
||||||
|
kubectl apply --dry-run=client -f k8s/digest-files/
|
||||||
|
```
|
||||||
|
|
||||||
|
(Run against a cluster with the `watch`/`postgres` namespaces and secrets
|
||||||
|
already created, since some manifests reference them.)
|
||||||
|
|
||||||
|
## Smoke-test checklist
|
||||||
|
|
||||||
|
- [ ] `kubectl get pods -n postgres` — postgres pod `Running`, `1/1` ready
|
||||||
|
- [ ] `kubectl get pods -n watch` — miniflux pod `Running`, `1/1` ready
|
||||||
|
- [ ] `kubectl logs -n watch deploy/miniflux` shows migrations applied, no errors
|
||||||
|
- [ ] `https://miniflux.sttlab.pc/healthcheck` returns `OK`
|
||||||
|
- [ ] Log in to the Miniflux UI with the admin credentials from the secret
|
||||||
|
- [ ] `kubectl logs -n watch job/bootstrap-feeds` shows categories/feeds created
|
||||||
|
- [ ] Categories and feeds appear in the Miniflux UI
|
||||||
|
- [ ] At least one feed shows fetched entries (Miniflux polls periodically; use "Refresh" in the UI to force it)
|
||||||
|
- [ ] `GET /v1/entries?status=unread` (with an API token) returns entries
|
||||||
|
- [ ] `kubectl get pods -n watch` — n8n pod `Running`, `1/1` ready
|
||||||
|
- [ ] `kubectl logs -n watch deploy/n8n` shows no DB connection errors
|
||||||
|
- [ ] `https://n8n.sttlab.pc/healthz` returns `{"status":"ok"}`
|
||||||
|
- [ ] Log in to the n8n UI and create the owner account
|
||||||
|
- [ ] n8n can reach Miniflux: an HTTP Request node to
|
||||||
|
`http://miniflux.watch.svc.cluster.local/v1/me` with the API token
|
||||||
|
returns the admin user
|
||||||
|
- [ ] "Tech Watch Digest" workflow imported, both credentials (Miniflux API,
|
||||||
|
LLM API) attached to their HTTP Request nodes
|
||||||
|
- [ ] A manual run ("Test workflow" in the UI, or a throwaway
|
||||||
|
`n8n execute --id=<id>` Job) produces a file under `/data/digests/`
|
||||||
|
in the n8n pod, and the corresponding Miniflux entries are marked read
|
||||||
|
- [ ] Workflow activated (`"active": true`)
|
||||||
|
- [ ] `kubectl get pods -n watch` — digest-files pod `Running`, `1/1` ready
|
||||||
|
- [ ] `https://digests.sttlab.pc/` lists the generated digest files
|
||||||
|
|
||||||
|
## Current status
|
||||||
|
|
||||||
|
Deployed and active: Miniflux (9 feeds), n8n, "Tech Watch Digest" workflow
|
||||||
|
(daily 06:00 Europe/Paris, OpenRouter `deepseek/deepseek-v4-flash`), and
|
||||||
|
the digest file server at `https://digests.sttlab.pc/`. No email delivery
|
||||||
|
(SMTP not configured) — digests are Markdown files served over HTTP
|
||||||
|
instead.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# nginx config for the digest file server: directory listing (autoindex)
|
||||||
|
# over the generated Markdown digests, no app logic involved.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: digest-files-nginx-conf
|
||||||
|
namespace: watch
|
||||||
|
data:
|
||||||
|
default.conf: |
|
||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
autoindex on;
|
||||||
|
autoindex_exact_size off;
|
||||||
|
autoindex_localtime on;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Read-only HTTP file server exposing the digest workflow's output
|
||||||
|
# (n8n-digest-workspace PVC, digests/ subdirectory) for browsing/download,
|
||||||
|
# instead of email delivery (no SMTP configured).
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: digest-files
|
||||||
|
namespace: watch
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: digest-files
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: digest-files
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: digest-files
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: nginx
|
||||||
|
image: nginxinc/nginx-unprivileged:alpine
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: 8080
|
||||||
|
volumeMounts:
|
||||||
|
- name: digests
|
||||||
|
mountPath: /usr/share/nginx/html
|
||||||
|
subPath: digests
|
||||||
|
readOnly: true
|
||||||
|
- name: nginx-conf
|
||||||
|
mountPath: /etc/nginx/conf.d/default.conf
|
||||||
|
subPath: default.conf
|
||||||
|
readOnly: true
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 25m
|
||||||
|
memory: 32Mi
|
||||||
|
limits:
|
||||||
|
cpu: 200m
|
||||||
|
memory: 128Mi
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 20
|
||||||
|
volumes:
|
||||||
|
- name: digests
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: n8n-digest-workspace
|
||||||
|
- name: nginx-conf
|
||||||
|
configMap:
|
||||||
|
name: digest-files-nginx-conf
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
apiVersion: gateway.networking.k8s.io/v1
|
||||||
|
kind: Gateway
|
||||||
|
metadata:
|
||||||
|
name: digest-files
|
||||||
|
namespace: watch
|
||||||
|
spec:
|
||||||
|
gatewayClassName: envoy
|
||||||
|
listeners:
|
||||||
|
- name: https
|
||||||
|
protocol: HTTPS
|
||||||
|
port: 443
|
||||||
|
hostname: digests.sttlab.pc
|
||||||
|
allowedRoutes:
|
||||||
|
namespaces:
|
||||||
|
from: Same
|
||||||
|
tls:
|
||||||
|
mode: Terminate
|
||||||
|
certificateRefs:
|
||||||
|
- kind: Secret
|
||||||
|
name: sttlab-pc-tls
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
apiVersion: gateway.networking.k8s.io/v1
|
||||||
|
kind: HTTPRoute
|
||||||
|
metadata:
|
||||||
|
name: digest-files
|
||||||
|
namespace: watch
|
||||||
|
spec:
|
||||||
|
parentRefs:
|
||||||
|
- group: gateway.networking.k8s.io
|
||||||
|
kind: Gateway
|
||||||
|
name: digest-files
|
||||||
|
hostnames:
|
||||||
|
- digests.sttlab.pc
|
||||||
|
rules:
|
||||||
|
- matches:
|
||||||
|
- path:
|
||||||
|
type: PathPrefix
|
||||||
|
value: /
|
||||||
|
backendRefs:
|
||||||
|
- kind: Service
|
||||||
|
name: digest-files
|
||||||
|
port: 80
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: digest-files
|
||||||
|
namespace: watch
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: digest-files
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app.kubernetes.io/name: digest-files
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 80
|
||||||
|
targetPort: 8080
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Runs miniflux-initializer (see /miniflux-initializer) to create the
|
||||||
|
# categories/feeds from feeds.yaml via the Miniflux REST API. Idempotent,
|
||||||
|
# safe to re-run: delete the Job then re-apply after editing feeds.yaml.
|
||||||
|
# kubectl delete job bootstrap-feeds -n watch --ignore-not-found
|
||||||
|
# kubectl apply -f k8s/miniflux/bootstrap-feeds-job.yaml
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: bootstrap-feeds
|
||||||
|
namespace: watch
|
||||||
|
spec:
|
||||||
|
backoffLimit: 3
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
restartPolicy: Never
|
||||||
|
containers:
|
||||||
|
- name: bootstrap-feeds
|
||||||
|
image: docker.io/library/miniflux-initializer:latest
|
||||||
|
imagePullPolicy: Never
|
||||||
|
env:
|
||||||
|
- name: MINIFLUX_URL
|
||||||
|
# Trailing dot makes this an absolute FQDN: musl's resolver
|
||||||
|
# (Alpine base image) mishandles ndots-relative lookups here
|
||||||
|
# and fails with EAI_AGAIN otherwise.
|
||||||
|
value: http://miniflux.watch.svc.cluster.local.
|
||||||
|
- name: MINIFLUX_USERNAME
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: miniflux-credentials
|
||||||
|
key: ADMIN_USERNAME
|
||||||
|
- name: MINIFLUX_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: miniflux-credentials
|
||||||
|
key: ADMIN_PASSWORD
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# One-off Job to create the "miniflux" database on the shared Postgres
|
||||||
|
# instance. Re-run (delete + kubectl apply) whenever a new app needs a
|
||||||
|
# database, with a copy of this Job using a different DB name.
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: create-db-miniflux
|
||||||
|
namespace: postgres
|
||||||
|
spec:
|
||||||
|
backoffLimit: 3
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
restartPolicy: Never
|
||||||
|
containers:
|
||||||
|
- name: create-db
|
||||||
|
image: postgres:18-alpine
|
||||||
|
envFrom:
|
||||||
|
- secretRef:
|
||||||
|
name: postgres-admin
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
set -e
|
||||||
|
psql "postgres://$POSTGRES_USER:$POSTGRES_PASSWORD@postgres-postgresql:5432/postgres" \
|
||||||
|
-tc "SELECT 1 FROM pg_database WHERE datname = 'miniflux'" | grep -q 1 \
|
||||||
|
|| psql "postgres://$POSTGRES_USER:$POSTGRES_PASSWORD@postgres-postgresql:5432/postgres" \
|
||||||
|
-c "CREATE DATABASE miniflux"
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: miniflux
|
||||||
|
namespace: watch
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: miniflux
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: miniflux
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: miniflux
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: miniflux
|
||||||
|
image: miniflux/miniflux:latest
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: 8080
|
||||||
|
envFrom:
|
||||||
|
- secretRef:
|
||||||
|
name: miniflux-credentials
|
||||||
|
env:
|
||||||
|
- name: RUN_MIGRATIONS
|
||||||
|
value: "1"
|
||||||
|
- name: CREATE_ADMIN
|
||||||
|
value: "1"
|
||||||
|
- name: BASE_URL
|
||||||
|
value: https://miniflux.sttlab.pc
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 256Mi
|
||||||
|
limits:
|
||||||
|
cpu: 1000m
|
||||||
|
memory: 768Mi
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /healthcheck
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /healthcheck
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 15
|
||||||
|
periodSeconds: 20
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
apiVersion: gateway.networking.k8s.io/v1
|
||||||
|
kind: Gateway
|
||||||
|
metadata:
|
||||||
|
name: miniflux
|
||||||
|
namespace: watch
|
||||||
|
spec:
|
||||||
|
gatewayClassName: envoy
|
||||||
|
listeners:
|
||||||
|
- name: https
|
||||||
|
protocol: HTTPS
|
||||||
|
port: 443
|
||||||
|
hostname: miniflux.sttlab.pc
|
||||||
|
allowedRoutes:
|
||||||
|
namespaces:
|
||||||
|
from: Same
|
||||||
|
tls:
|
||||||
|
mode: Terminate
|
||||||
|
certificateRefs:
|
||||||
|
- kind: Secret
|
||||||
|
name: sttlab-pc-tls
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
apiVersion: gateway.networking.k8s.io/v1
|
||||||
|
kind: HTTPRoute
|
||||||
|
metadata:
|
||||||
|
name: miniflux
|
||||||
|
namespace: watch
|
||||||
|
spec:
|
||||||
|
parentRefs:
|
||||||
|
- group: gateway.networking.k8s.io
|
||||||
|
kind: Gateway
|
||||||
|
name: miniflux
|
||||||
|
hostnames:
|
||||||
|
- miniflux.sttlab.pc
|
||||||
|
rules:
|
||||||
|
- matches:
|
||||||
|
- path:
|
||||||
|
type: PathPrefix
|
||||||
|
value: /
|
||||||
|
backendRefs:
|
||||||
|
- kind: Service
|
||||||
|
name: miniflux
|
||||||
|
port: 80
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Copy to secret.yaml, fill in real values, and apply with:
|
||||||
|
# kubectl apply -f secret.yaml
|
||||||
|
# Never commit the filled-in secret.yaml to git.
|
||||||
|
#
|
||||||
|
# POSTGRES_USER/POSTGRES_PASSWORD must match k8s/postgres/secret.yaml.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: miniflux-credentials
|
||||||
|
namespace: watch
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
DATABASE_URL: postgres://postgres:changeme@postgres-postgresql.postgres.svc.cluster.local:5432/miniflux?sslmode=disable
|
||||||
|
ADMIN_USERNAME: admin
|
||||||
|
ADMIN_PASSWORD: changeme
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: miniflux
|
||||||
|
namespace: watch
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: miniflux
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app.kubernetes.io/name: miniflux
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 80
|
||||||
|
targetPort: 8080
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# One-off Job to create the "n8n" database on the shared Postgres instance.
|
||||||
|
# Mirrors k8s/miniflux/create-db-job.yaml.
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: create-db-n8n
|
||||||
|
namespace: postgres
|
||||||
|
spec:
|
||||||
|
backoffLimit: 3
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
restartPolicy: Never
|
||||||
|
containers:
|
||||||
|
- name: create-db
|
||||||
|
image: postgres:18-alpine
|
||||||
|
envFrom:
|
||||||
|
- secretRef:
|
||||||
|
name: postgres-admin
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
set -e
|
||||||
|
psql "postgres://$POSTGRES_USER:$POSTGRES_PASSWORD@postgres-postgresql:5432/postgres" \
|
||||||
|
-tc "SELECT 1 FROM pg_database WHERE datname = 'n8n'" | grep -q 1 \
|
||||||
|
|| psql "postgres://$POSTGRES_USER:$POSTGRES_PASSWORD@postgres-postgresql:5432/postgres" \
|
||||||
|
-c "CREATE DATABASE n8n"
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: n8n
|
||||||
|
namespace: watch
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: n8n
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
strategy:
|
||||||
|
type: Recreate # single ReadWriteOnce PVC, avoid two pods mounting it at once
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: n8n
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: n8n
|
||||||
|
spec:
|
||||||
|
securityContext:
|
||||||
|
fsGroup: 1000 # matches the "node" user baked into the n8n image
|
||||||
|
containers:
|
||||||
|
- name: n8n
|
||||||
|
image: n8nio/n8n:latest
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: 5678
|
||||||
|
envFrom:
|
||||||
|
- secretRef:
|
||||||
|
name: n8n-credentials
|
||||||
|
env:
|
||||||
|
- name: DB_TYPE
|
||||||
|
value: postgresdb
|
||||||
|
- name: DB_POSTGRESDB_HOST
|
||||||
|
value: postgres-postgresql.postgres.svc.cluster.local
|
||||||
|
- name: DB_POSTGRESDB_PORT
|
||||||
|
value: "5432"
|
||||||
|
- name: DB_POSTGRESDB_DATABASE
|
||||||
|
value: n8n
|
||||||
|
- name: N8N_HOST
|
||||||
|
value: n8n.sttlab.pc
|
||||||
|
- name: N8N_PORT
|
||||||
|
value: "5678"
|
||||||
|
- name: N8N_PROTOCOL
|
||||||
|
value: https
|
||||||
|
- name: N8N_WEBHOOK_URL
|
||||||
|
value: https://n8n.sttlab.pc/
|
||||||
|
- name: GENERIC_TIMEZONE
|
||||||
|
value: Europe/Paris
|
||||||
|
- name: TZ
|
||||||
|
value: Europe/Paris
|
||||||
|
- name: N8N_RESTRICT_FILE_ACCESS_TO
|
||||||
|
value: "/data" # single path only (no list support)
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /home/node/.n8n
|
||||||
|
- name: digest-workspace
|
||||||
|
mountPath: /data # dedicated volume for Read/Write File nodes, separate from n8n's own
|
||||||
|
# internal directory (which n8n always blocks file-node access to, regardless of
|
||||||
|
# N8N_RESTRICT_FILE_ACCESS_TO, via N8N_BLOCK_FILE_ACCESS_TO_N8N_FILES)
|
||||||
|
- name: digest-prompt
|
||||||
|
mountPath: /data/prompts/digest.md
|
||||||
|
subPath: digest.md # avoids the ConfigMap symlink indirection, which trips n8n's file-access allowlist check
|
||||||
|
readOnly: true
|
||||||
|
- name: digest-prompt
|
||||||
|
mountPath: /data/prompts/editorial.md
|
||||||
|
subPath: editorial.md
|
||||||
|
readOnly: true
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 200m
|
||||||
|
memory: 512Mi
|
||||||
|
limits:
|
||||||
|
cpu: 1000m
|
||||||
|
memory: 2Gi
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /healthz
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 10
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /healthz
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 20
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: n8n-data
|
||||||
|
- name: digest-workspace
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: n8n-digest-workspace
|
||||||
|
- name: digest-prompt
|
||||||
|
configMap:
|
||||||
|
name: n8n-digest-prompt
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
apiVersion: gateway.networking.k8s.io/v1
|
||||||
|
kind: Gateway
|
||||||
|
metadata:
|
||||||
|
name: n8n
|
||||||
|
namespace: watch
|
||||||
|
spec:
|
||||||
|
gatewayClassName: envoy
|
||||||
|
listeners:
|
||||||
|
- name: https
|
||||||
|
protocol: HTTPS
|
||||||
|
port: 443
|
||||||
|
hostname: n8n.sttlab.pc
|
||||||
|
allowedRoutes:
|
||||||
|
namespaces:
|
||||||
|
from: Same
|
||||||
|
tls:
|
||||||
|
mode: Terminate
|
||||||
|
certificateRefs:
|
||||||
|
- kind: Secret
|
||||||
|
name: sttlab-pc-tls
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
apiVersion: gateway.networking.k8s.io/v1
|
||||||
|
kind: HTTPRoute
|
||||||
|
metadata:
|
||||||
|
name: n8n
|
||||||
|
namespace: watch
|
||||||
|
spec:
|
||||||
|
parentRefs:
|
||||||
|
- group: gateway.networking.k8s.io
|
||||||
|
kind: Gateway
|
||||||
|
name: n8n
|
||||||
|
hostnames:
|
||||||
|
- n8n.sttlab.pc
|
||||||
|
rules:
|
||||||
|
- matches:
|
||||||
|
- path:
|
||||||
|
type: PathPrefix
|
||||||
|
value: /
|
||||||
|
backendRefs:
|
||||||
|
- kind: Service
|
||||||
|
name: n8n
|
||||||
|
port: 80
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Copy to miniflux-token-secret.yaml, fill in the real token, and apply with:
|
||||||
|
# kubectl apply -f miniflux-token-secret.yaml
|
||||||
|
# Never commit the filled-in file to git.
|
||||||
|
#
|
||||||
|
# Generate the token in the Miniflux UI under Settings -> API Keys (no
|
||||||
|
# public REST endpoint creates one). Kept here as the source of truth /
|
||||||
|
# backup; the digest workflow itself should use an n8n "Header Auth"
|
||||||
|
# credential (header name X-Auth-Token) created in the n8n UI from these
|
||||||
|
# same values, not a raw env var — n8n blocks $env access from workflow
|
||||||
|
# expressions by default (N8N_BLOCK_ENV_ACCESS_IN_NODE), so this Secret is
|
||||||
|
# not wired into the n8n Deployment's envFrom.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: miniflux-api-token
|
||||||
|
namespace: watch
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
MINIFLUX_URL: https://miniflux.sttlab.pc/v1/
|
||||||
|
MINIFLUX_API_TOKEN: changeme
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Persists /home/node/.n8n (encryption key backup, local binary data cache,
|
||||||
|
# community nodes). Workflow/execution data itself lives in Postgres.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: n8n-data
|
||||||
|
namespace: watch
|
||||||
|
spec:
|
||||||
|
accessModes: ["ReadWriteOnce"]
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 2Gi
|
||||||
|
---
|
||||||
|
# Separate volume for Read/Write File nodes (digest prompt + output), kept
|
||||||
|
# apart from n8n-data (n8n's own internal directory, which n8n always
|
||||||
|
# blocks file-node access to regardless of N8N_RESTRICT_FILE_ACCESS_TO).
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: n8n-digest-workspace
|
||||||
|
namespace: watch
|
||||||
|
spec:
|
||||||
|
accessModes: ["ReadWriteOnce"]
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 1Gi
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Copy to secret.yaml, fill in real values, and apply with:
|
||||||
|
# kubectl apply -f secret.yaml
|
||||||
|
# Never commit the filled-in secret.yaml to git.
|
||||||
|
#
|
||||||
|
# DB_POSTGRESDB_USER/PASSWORD must match k8s/postgres/secret.yaml.
|
||||||
|
#
|
||||||
|
# N8N_ENCRYPTION_KEY encrypts credentials stored in the n8n database.
|
||||||
|
# Generate once with `openssl rand -hex 24` and never change it afterwards
|
||||||
|
# (rotating it locks n8n out of every credential already saved).
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: n8n-credentials
|
||||||
|
namespace: watch
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
DB_POSTGRESDB_USER: postgres
|
||||||
|
DB_POSTGRESDB_PASSWORD: changeme
|
||||||
|
N8N_ENCRYPTION_KEY: changeme
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: n8n
|
||||||
|
namespace: watch
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: n8n
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app.kubernetes.io/name: n8n
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 80
|
||||||
|
targetPort: 5678
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: watch
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: postgres
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Copy to secret.yaml, fill in a real password, and apply with:
|
||||||
|
# kubectl apply -f secret.yaml
|
||||||
|
# Never commit the filled-in secret.yaml to git.
|
||||||
|
#
|
||||||
|
# This is the Postgres superuser used to provision one database per
|
||||||
|
# application (see create-db-*-job.yaml). Applications connect with these
|
||||||
|
# same credentials against their own database (no per-app roles for now).
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: postgres-admin
|
||||||
|
namespace: postgres
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: changeme
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: postgres
|
||||||
|
namespace: postgres
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: postgres
|
||||||
|
spec:
|
||||||
|
clusterIP: None
|
||||||
|
selector:
|
||||||
|
app.kubernetes.io/name: postgres
|
||||||
|
ports:
|
||||||
|
- name: postgres
|
||||||
|
port: 5432
|
||||||
|
targetPort: 5432
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Shared PostgreSQL instance, reusable across applications (miniflux, n8n, ...).
|
||||||
|
# Each application gets its own database (see create-db-*-job.yaml), all
|
||||||
|
# reachable through the "postgres" Service below.
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: StatefulSet
|
||||||
|
metadata:
|
||||||
|
name: postgres
|
||||||
|
namespace: postgres
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: postgres
|
||||||
|
spec:
|
||||||
|
serviceName: postgres
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: postgres
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: postgres
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: postgres
|
||||||
|
image: postgres:18-alpine
|
||||||
|
ports:
|
||||||
|
- name: postgres
|
||||||
|
containerPort: 5432
|
||||||
|
envFrom:
|
||||||
|
- secretRef:
|
||||||
|
name: postgres-admin
|
||||||
|
env:
|
||||||
|
- name: PGDATA
|
||||||
|
value: /var/lib/postgresql/data/pgdata
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /var/lib/postgresql/data
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 256Mi
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 512Mi
|
||||||
|
readinessProbe:
|
||||||
|
exec:
|
||||||
|
command: ["pg_isready", "-U", "$(POSTGRES_USER)"]
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
livenessProbe:
|
||||||
|
exec:
|
||||||
|
command: ["pg_isready", "-U", "$(POSTGRES_USER)"]
|
||||||
|
initialDelaySeconds: 15
|
||||||
|
periodSeconds: 20
|
||||||
|
volumeClaimTemplates:
|
||||||
|
- metadata:
|
||||||
|
name: data
|
||||||
|
spec:
|
||||||
|
accessModes: ["ReadWriteOnce"]
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 5Gi
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
FROM python:3.14-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY bootstrap_feeds.py feeds.yaml ./
|
||||||
|
|
||||||
|
ENTRYPOINT ["python3", "bootstrap_feeds.py"]
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Create Miniflux categories and feeds from feeds.yaml.
|
||||||
|
|
||||||
|
Env vars required: MINIFLUX_URL, MINIFLUX_USERNAME, MINIFLUX_PASSWORD.
|
||||||
|
|
||||||
|
Idempotent: existing categories are matched by title, existing feeds are
|
||||||
|
matched by feed_url, both fetched from the API before any create call.
|
||||||
|
Safe to re-run (e.g. every time the Job runs) — only new entries get
|
||||||
|
created.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
MINIFLUX_URL = os.environ["MINIFLUX_URL"].rstrip("/")
|
||||||
|
AUTH = (os.environ["MINIFLUX_USERNAME"], os.environ["MINIFLUX_PASSWORD"])
|
||||||
|
FEEDS_FILE = os.path.join(os.path.dirname(__file__), "feeds.yaml")
|
||||||
|
|
||||||
|
|
||||||
|
def api(method, path, **kwargs):
|
||||||
|
resp = requests.request(method, f"{MINIFLUX_URL}/v1{path}", auth=AUTH, timeout=60, **kwargs)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json() if resp.content else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_category(name, existing_categories):
|
||||||
|
if name in existing_categories:
|
||||||
|
return existing_categories[name]
|
||||||
|
category = api("POST", "/categories", json={"title": name})
|
||||||
|
existing_categories[name] = category["id"]
|
||||||
|
print(f"created category: {name}")
|
||||||
|
return category["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
with open(FEEDS_FILE) as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
|
||||||
|
existing_categories = {c["title"]: c["id"] for c in api("GET", "/categories")}
|
||||||
|
existing_feed_urls = {f["feed_url"] for f in api("GET", "/feeds")}
|
||||||
|
|
||||||
|
for category in data["categories"]:
|
||||||
|
category_id = get_or_create_category(category["name"], existing_categories)
|
||||||
|
for feed in category["feeds"]:
|
||||||
|
if feed["url"] in existing_feed_urls:
|
||||||
|
print(f"skip (already added): {feed['title']}")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
api(
|
||||||
|
"POST",
|
||||||
|
"/feeds",
|
||||||
|
json={
|
||||||
|
"feed_url": feed["url"],
|
||||||
|
"category_id": category_id,
|
||||||
|
"crawler": feed.get("crawler", False),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
existing_feed_urls.add(feed["url"])
|
||||||
|
print(f"added feed: {feed['title']} ({feed['url']})")
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
print(f"FAILED: {feed['title']} ({feed['url']}): {exc}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
# Initial feed list. Kept intentionally small: one or two
|
||||||
|
# confirmed-working (HTTP 200) feeds per category from AGENTS.md.
|
||||||
|
# Add more later with the same structure — see README.md "How to add a source".
|
||||||
|
# crawler: true makes Miniflux fetch the original article page instead of
|
||||||
|
# relying on the (often truncated) RSS/Atom excerpt.
|
||||||
|
categories:
|
||||||
|
- name: Cloud Native
|
||||||
|
feeds:
|
||||||
|
- title: CNCF blog
|
||||||
|
url: https://www.cncf.io/feed/
|
||||||
|
crawler: true
|
||||||
|
- title: Kubernetes blog
|
||||||
|
url: https://kubernetes.io/feed.xml
|
||||||
|
crawler: true
|
||||||
|
- title: AWS Architecture blog
|
||||||
|
url: https://aws.amazon.com/blogs/architecture/feed/
|
||||||
|
crawler: true
|
||||||
|
- title: Docker blog
|
||||||
|
url: https://www.docker.com/feed/
|
||||||
|
crawler: true
|
||||||
|
- title: HashiCorp blog (news)
|
||||||
|
url: https://news.google.com/rss/search?q=%22HashiCorp%22%20blog&hl=en-US&gl=US&ceid=US:en
|
||||||
|
crawler: true
|
||||||
|
- title: Red Hat blog (news)
|
||||||
|
url: https://news.google.com/rss/search?q=%22Red%20Hat%22%20blog&hl=en-US&gl=US&ceid=US:en
|
||||||
|
crawler: true
|
||||||
|
- title: Google Cloud blog
|
||||||
|
url: https://cloudblog.withgoogle.com/rss/
|
||||||
|
crawler: true
|
||||||
|
- title: Azure blog
|
||||||
|
url: https://azure.microsoft.com/en-us/blog/feed/
|
||||||
|
crawler: true
|
||||||
|
|
||||||
|
- name: Integration
|
||||||
|
feeds:
|
||||||
|
- title: Google News search
|
||||||
|
url: https://news.google.com/rss/search?q=%22hybrid%20integration%22%20OR%20iPaaS%20OR%20%22integration%20platform%22%20OR%20%22enterprise%20service%20bus%22%20OR%20%22application%20integration%22%20OR%20webMethods%20OR%20%22IBM%20App%20Connect%22%20OR%20Boomi%20OR%20TIBCO%20OR%20MuleSoft%20OR%20Workato&hl=en-US&gl=US&ceid=US:en
|
||||||
|
crawler: true
|
||||||
|
|
||||||
|
- name: APIM
|
||||||
|
feeds:
|
||||||
|
- title: Google News search
|
||||||
|
url: https://news.google.com/rss/search?q=%22API%20management%22%20OR%20%22API%20gateway%22%20OR%20%22API%20economy%22%20OR%20OpenAPI%20OR%20AsyncAPI%20OR%20%22API%20security%22%20OR%20Apigee%20OR%20%22Kong%20Gateway%22%20OR%20%22Azure%20API%20Management%22%20OR%20%22Gravitee.io%22%20OR%20%22Tyk%20API%22%20OR%20WSO2%20OR%20%22Amazon%20API%20Gateway%22%20OR%20%22IBM%20API%20Connect%22%20OR%20%22Axway%20Amplify%22%20OR%20%22Postman%20API%22&hl=en-US&gl=US&ceid=US:en
|
||||||
|
crawler: true
|
||||||
|
|
||||||
|
- name: EDA
|
||||||
|
feeds:
|
||||||
|
- title: Confluent blog
|
||||||
|
url: https://www.confluent.io/feed/
|
||||||
|
crawler: true
|
||||||
|
- title: Kai Waehner
|
||||||
|
url: https://www.kai-waehner.de/feed/
|
||||||
|
crawler: true
|
||||||
|
- title: Solace blog
|
||||||
|
url: https://solace.com/feed/
|
||||||
|
crawler: true
|
||||||
|
- title: RabbitMQ blog
|
||||||
|
url: https://www.rabbitmq.com/blog/rss.xml # verify
|
||||||
|
crawler: true
|
||||||
|
- title: AsyncAPI blog
|
||||||
|
url: https://www.asyncapi.com/rss.xml # verify
|
||||||
|
crawler: true
|
||||||
|
|
||||||
|
- name: AI
|
||||||
|
feeds:
|
||||||
|
- title: OpenAI blog
|
||||||
|
url: https://openai.com/blog/rss.xml
|
||||||
|
crawler: false
|
||||||
|
- title: Simon Willison
|
||||||
|
url: https://simonwillison.net/atom/everything/
|
||||||
|
crawler: true
|
||||||
|
- title: Anthropic news
|
||||||
|
url: https://rsshub.bestblogs.dev/anthropic/news
|
||||||
|
crawler: true
|
||||||
|
- title: Huggingface blog
|
||||||
|
url: https://huggingface.co/blog/feed.xml
|
||||||
|
crawler: true
|
||||||
|
- title: Techcrunch AI
|
||||||
|
url: https://techcrunch.com/category/artificial-intelligence/feed/
|
||||||
|
crawler: true
|
||||||
|
- title: Last Week in AI
|
||||||
|
url: https://lastweekin.ai/feed/
|
||||||
|
crawler: true
|
||||||
|
- title: The Batch (Andrew Ng) (news)
|
||||||
|
url: https://news.google.com/rss/search?q=%22The%20Batch%22%20%22DeepLearning.AI%22&hl=en-US&gl=US&ceid=US:en
|
||||||
|
crawler: true
|
||||||
|
- title: Microsoft AI blog (news)
|
||||||
|
url: https://news.google.com/rss/search?q=%22Microsoft%20AI%22%20blog&hl=en-US&gl=US&ceid=US:en
|
||||||
|
crawler: true
|
||||||
|
- title: LangChain blog (news)
|
||||||
|
url: https://news.google.com/rss/search?q=%22LangChain%22%20blog&hl=en-US&gl=US&ceid=US:en
|
||||||
|
crawler: true
|
||||||
|
- title: Latent Space
|
||||||
|
url: https://www.latent.space/feed
|
||||||
|
crawler: false # Substack, contenu complet dans le flux
|
||||||
|
- title: MCP releases
|
||||||
|
url: https://github.com/modelcontextprotocol/modelcontextprotocol/releases.atom
|
||||||
|
crawler: false
|
||||||
|
- title: ARC Prize blog (news)
|
||||||
|
url: https://news.google.com/rss/search?q=%22ARC%20Prize%22&hl=en-US&gl=US&ceid=US:en
|
||||||
|
crawler: true
|
||||||
|
- title: Baldur Bjarnason
|
||||||
|
url: https://www.baldurbjarnason.com/feed.xml
|
||||||
|
crawler: false # contenu complet dans le flux
|
||||||
|
- title: Yann LeCun (news)
|
||||||
|
url: https://news.google.com/rss/search?q=%22Yann%20LeCun%22&hl=en-US&gl=US&ceid=US:en
|
||||||
|
crawler: true
|
||||||
|
- title: Francois Chollet (news)
|
||||||
|
url: https://news.google.com/rss/search?q=%22Fran%C3%A7ois%20Chollet%22%20OR%20%22Francois%20Chollet%22&hl=en-US&gl=US&ceid=US:en
|
||||||
|
crawler: true
|
||||||
|
|
||||||
|
- name: Security
|
||||||
|
feeds:
|
||||||
|
- title: Bleeping Computer
|
||||||
|
url: https://www.bleepingcomputer.com/feed/
|
||||||
|
crawler: true
|
||||||
|
- title: CISA advisories
|
||||||
|
url: https://www.cisa.gov/cybersecurity-advisories/all.xml
|
||||||
|
crawler: false
|
||||||
|
- title: Hacker News
|
||||||
|
url: https://news.ycombinator.com/rss
|
||||||
|
crawler: false
|
||||||
|
|
||||||
|
- name: Sovereignty
|
||||||
|
feeds:
|
||||||
|
- title: Digital sovereignty (news)
|
||||||
|
url: https://news.google.com/rss/search?q=%22digital%20sovereignty%22%20OR%20%22sovereign%20cloud%22%20OR%20%22cloud%20souverain%22&hl=en-US&gl=US&ceid=US:en
|
||||||
|
crawler: true
|
||||||
|
- title: EU regulation - AI Act / Data Act / GAIA-X (news)
|
||||||
|
url: https://news.google.com/rss/search?q=(%22AI%20Act%22%20OR%20%22Data%20Act%22%20OR%20GAIA-X%20OR%20EuroHPC)%20(Europe%20OR%20EU)&hl=en-US&gl=US&ceid=US:en
|
||||||
|
crawler: true
|
||||||
|
- title: Souverainete numerique FR (news)
|
||||||
|
url: https://news.google.com/rss/search?q=(souverainet%C3%A9%20num%C3%A9rique%20OR%20%22cloud%20de%20confiance%22%20OR%20SecNumCloud)&hl=fr&gl=FR&ceid=FR:fr
|
||||||
|
crawler: true
|
||||||
|
|
||||||
|
- name: Analysts
|
||||||
|
feeds:
|
||||||
|
- title: Sanjeev Mohan
|
||||||
|
url: https://news.google.com/rss/search?q=%22Sanjeev%20Mohan%22&hl=en-US&gl=US&ceid=US:en
|
||||||
|
crawler: true
|
||||||
|
- title: Massimo Pezzini
|
||||||
|
url: https://news.google.com/rss/search?q=%22Massimo%20Pezzini%22&hl=en-US&gl=US&ceid=US:en
|
||||||
|
crawler: true
|
||||||
|
- title: Gregor Hohpe
|
||||||
|
url: https://news.google.com/rss/search?q=%22Gregor%20Hohpe%22&hl=en-US&gl=US&ceid=US:en
|
||||||
|
crawler: true
|
||||||
|
- title: Kai Waehner
|
||||||
|
url: https://news.google.com/rss/search?q=%22Kai%20Waehner%22&hl=en-US&gl=US&ceid=US:en
|
||||||
|
crawler: true
|
||||||
|
- title: Martin Fowler
|
||||||
|
url: https://martinfowler.com/feed.atom
|
||||||
|
crawler: true
|
||||||
|
|
||||||
|
- name: Others
|
||||||
|
feeds:
|
||||||
|
- title: The Register
|
||||||
|
url: https://www.theregister.com/headlines.atom
|
||||||
|
crawler: true
|
||||||
|
- title: InfoQ
|
||||||
|
url: https://feed.infoq.com/
|
||||||
|
crawler: true
|
||||||
|
- title: MIT Technology Review
|
||||||
|
url: https://www.technologyreview.com/feed/
|
||||||
|
crawler: true
|
||||||
|
- title: Open Source Projects
|
||||||
|
url: https://www.opensourceprojects.dev/rss
|
||||||
|
crawler: true
|
||||||
|
- title: Reddit /r/programming
|
||||||
|
url: https://www.reddit.com/r/programming/.rss
|
||||||
|
crawler: false
|
||||||
|
- title: Reddit /r/technology
|
||||||
|
url: https://www.reddit.com/r/technology/.rss
|
||||||
|
crawler: false
|
||||||
|
- title: GitHub blog
|
||||||
|
url: https://github.blog/feed/
|
||||||
|
crawler: true
|
||||||
|
- title: Wired
|
||||||
|
url: https://www.wired.com/feed/rss
|
||||||
|
crawler: true
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
requests
|
||||||
|
PyYAML
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
LANGUAGE RULE, READ FIRST: every piece of natural-language text you write in
|
||||||
|
your response — every "summary", every "weak_signals" entry — MUST be in
|
||||||
|
French. The source articles you're given are in English; you read them in
|
||||||
|
English, but you WRITE ONLY IN FRENCH. Do not let the input language leak
|
||||||
|
into your output. Do not write a single English sentence, clause, or
|
||||||
|
dangling English phrase inside a French sentence. If you catch yourself
|
||||||
|
drafting in English, translate it before responding. This rule applies no
|
||||||
|
matter what — there is no case where an English summary is acceptable.
|
||||||
|
|
||||||
|
You are curating a DAILY technology-watch digest for a solutions architect
|
||||||
|
working on application/data integration (iPaaS, ESB, API management),
|
||||||
|
event-driven architecture (Kafka, AMQP, MQTT, AsyncAPI, CloudEvents),
|
||||||
|
agentic AI / LLM integration / MCP, cloud native / Kubernetes / platform
|
||||||
|
engineering, and MFT / B2B / EDI integration.
|
||||||
|
|
||||||
|
You will be given one topic category and a list of unread articles in that
|
||||||
|
category (title, URL, and excerpt/content), covering roughly the last 24
|
||||||
|
hours. Your job:
|
||||||
|
|
||||||
|
1. Select every item that is genuinely relevant and significant for this
|
||||||
|
reader. Do not artificially cap the count to a fixed number — a busy
|
||||||
|
day can have many, a quiet day can have few or none. Relevance means:
|
||||||
|
concrete technical substance, a notable release, security advisory,
|
||||||
|
architecture pattern, or market-moving event (funding, acquisition,
|
||||||
|
major partnership) in the domains above — not generic marketing posts.
|
||||||
|
2. Write a 1-2 sentence summary of each selected item. **The summary text
|
||||||
|
must be written in French — always, even though the source articles
|
||||||
|
and this prompt are in English.** Dense and factual (no fluff, no
|
||||||
|
"cet article explique que...").
|
||||||
|
3. List any remaining items that are minor but still worth a passing
|
||||||
|
mention as "weak signals". Each weak signal MUST correspond to one
|
||||||
|
specific article from the input that you did NOT already put in
|
||||||
|
top_items — never restate, generalize, or editorialize about an item
|
||||||
|
that is already in top_items. Each one is a short standalone phrase **in
|
||||||
|
French**, a few words only (not a sentence, no elaboration, just enough
|
||||||
|
to identify the topic). Skip items that are pure noise (ads, unrelated
|
||||||
|
content).
|
||||||
|
|
||||||
|
Respond with **strict JSON only**, no markdown fences, no commentary,
|
||||||
|
matching exactly this shape:
|
||||||
|
|
||||||
|
{
|
||||||
|
"top_items": [
|
||||||
|
{"title": "...", "url": "...", "summary": "..."}
|
||||||
|
],
|
||||||
|
"weak_signals": ["...", "..."]
|
||||||
|
}
|
||||||
|
|
||||||
|
Every "summary" and every "weak_signals" entry must be in French — this is
|
||||||
|
required, not a suggestion, and it is the single most common mistake to
|
||||||
|
avoid: do not respond in English. "title" and "url" are copied verbatim from
|
||||||
|
the input, unchanged (these two fields only stay in their original
|
||||||
|
language). If the category has no article worth surfacing at all, return
|
||||||
|
empty arrays for both fields. Never invent articles or URLs that were not
|
||||||
|
given to you.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
LANGUAGE RULE, READ FIRST: every piece of natural-language text you write —
|
||||||
|
"lead" and every "highlights" entry — MUST be in French. The items you're
|
||||||
|
given are already French summaries, but if you draft anything in English,
|
||||||
|
translate it before responding. No English words, clauses, or phrases
|
||||||
|
anywhere in your output.
|
||||||
|
|
||||||
|
You are the editorial voice for a DAILY technology-watch digest, running
|
||||||
|
after a mechanical selection pass has already picked the day's articles per
|
||||||
|
category (application/data integration, event-driven architecture, agentic
|
||||||
|
AI/LLM/MCP, cloud native/Kubernetes, security, digital sovereignty, industry
|
||||||
|
analysts, and others) for a solutions architect reader.
|
||||||
|
|
||||||
|
You will be given a JSON array of categories, each with a list of items
|
||||||
|
already selected for today (title and summary, in French). This selection is
|
||||||
|
final — you are not editing it, only writing about it.
|
||||||
|
|
||||||
|
Your job: write a short **editorial synthesis** of the day, to be placed at
|
||||||
|
the top of the digest before the category-by-category lists. This is not a
|
||||||
|
recap or a list of the items — the reader will see the full lists right
|
||||||
|
below. Instead:
|
||||||
|
|
||||||
|
1. Identify what actually matters most today across the whole set — the
|
||||||
|
1-3 stories or developments most worth the reader's attention, and why.
|
||||||
|
2. Connect the dots where relevant: if multiple items across categories
|
||||||
|
relate to the same underlying trend, story, or tension (e.g. the same
|
||||||
|
event covered from different angles, or a pattern emerging across
|
||||||
|
several independent items), say so explicitly — this is exactly the kind
|
||||||
|
of cross-category perspective the mechanical per-category pass cannot
|
||||||
|
produce.
|
||||||
|
3. Give real editorial judgment: what's significant vs. noise, what's likely
|
||||||
|
to matter in the coming weeks, what a reader in this domain (iPaaS/ESB,
|
||||||
|
EDA, agentic AI, cloud native, MFT/EDI) should actually take away. Light,
|
||||||
|
opinionated framing is welcome — this is analysis, not a summary.
|
||||||
|
4. Stay factual and grounded strictly in the items given to you. Do not
|
||||||
|
invent facts, events, or items not present in the input.
|
||||||
|
|
||||||
|
**Format matters as much as content.** Do NOT write one long dense
|
||||||
|
paragraph — it is hard to scan and gets skipped. Instead produce:
|
||||||
|
|
||||||
|
- "lead": ONE short sentence (max ~25 words) stating the single most
|
||||||
|
important takeaway of the day. This is the headline of your analysis.
|
||||||
|
- "highlights": 2 to 5 short bullet points (each ONE sentence, ideally
|
||||||
|
under 25 words), each surfacing one distinct connection, tension, or
|
||||||
|
noteworthy pattern across the day's items. Each bullet stands on its own
|
||||||
|
— a reader should be able to read just the bullets and get the gist.
|
||||||
|
Do not pad to reach 5; 2-3 sharp bullets beat 5 mediocre ones.
|
||||||
|
|
||||||
|
If the day's selection is genuinely thin or scattered with no meaningful
|
||||||
|
throughline, it's fine to say so briefly in "lead" and keep "highlights"
|
||||||
|
short or empty rather than forcing false connections.
|
||||||
|
|
||||||
|
Respond with **strict JSON only**, no markdown fences, no commentary,
|
||||||
|
matching exactly this shape:
|
||||||
|
|
||||||
|
{
|
||||||
|
"lead": "...",
|
||||||
|
"highlights": ["...", "..."]
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
{
|
||||||
|
"id": "CAbGgPd187VA7K1R",
|
||||||
|
"name": "Tech Watch Digest",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "schedule_trigger",
|
||||||
|
"name": "Schedule Trigger",
|
||||||
|
"type": "n8n-nodes-base.scheduleTrigger",
|
||||||
|
"typeVersion": 1.2,
|
||||||
|
"position": [0, 0],
|
||||||
|
"parameters": {
|
||||||
|
"rule": {
|
||||||
|
"interval": [
|
||||||
|
{ "field": "cronExpression", "expression": "0 6 * * *" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "init",
|
||||||
|
"name": "Init",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [220, 0],
|
||||||
|
"parameters": {
|
||||||
|
"mode": "runOnceForAllItems",
|
||||||
|
"jsCode": "const now = new Date();\nconst oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);\nreturn [{\n json: {\n publishedAfter: Math.floor(oneDayAgo.getTime() / 1000),\n digestDate: now.toISOString().slice(0, 10),\n llmBaseUrl: 'https://openrouter.ai/api/v1',\n llmModel: 'deepseek/deepseek-v4-flash',\n editorialModel: 'anthropic/claude-opus-4.8'\n }\n}];"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "get_unread_entries",
|
||||||
|
"name": "Get Unread Entries",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [440, 0],
|
||||||
|
"parameters": {
|
||||||
|
"method": "GET",
|
||||||
|
"url": "http://miniflux.watch.svc.cluster.local/v1/entries",
|
||||||
|
"authentication": "genericCredentialType",
|
||||||
|
"genericAuthType": "httpHeaderAuth",
|
||||||
|
"sendQuery": true,
|
||||||
|
"queryParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "status", "value": "unread" },
|
||||||
|
{ "name": "limit", "value": "500" },
|
||||||
|
{ "name": "order", "value": "published_at" },
|
||||||
|
{ "name": "direction", "value": "desc" },
|
||||||
|
{ "name": "published_after", "value": "={{ $json.publishedAfter }}" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"credentials": {
|
||||||
|
"httpHeaderAuth": { "id": "D3czTdAQl3KhiKTM", "name": "Miniflux API" }
|
||||||
|
},
|
||||||
|
"retryOnFail": true,
|
||||||
|
"maxTries": 3,
|
||||||
|
"waitBetweenTries": 2000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "group_by_category",
|
||||||
|
"name": "Group By Category",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [660, 0],
|
||||||
|
"parameters": {
|
||||||
|
"mode": "runOnceForAllItems",
|
||||||
|
"jsCode": "const resp = $input.first().json;\nlet entries = resp.entries || [];\n\n// Dedup across ALL categories by normalized title: several feeds (direct\n// blogs + overlapping Google News searches) can surface the same story\n// under different URLs/categories, and each category is summarized by the\n// LLM independently so nothing else catches this.\nconst seenTitles = new Set();\nentries = entries.filter((e) => {\n const key = (e.title || '').toLowerCase().trim().replace(/[^a-z0-9]+/g, ' ').trim();\n if (!key || seenTitles.has(key)) return false;\n seenTitles.add(key);\n return true;\n});\n\nconst byCategory = {};\nfor (const e of entries) {\n const cat = (e.feed && e.feed.category && e.feed.category.title) || 'Uncategorized';\n if (!byCategory[cat]) byCategory[cat] = [];\n byCategory[cat].push({\n id: e.id,\n title: e.title,\n url: e.url,\n published_at: e.published_at,\n feed_title: e.feed ? e.feed.title : '',\n excerpt: (e.content || '').replace(/<[^>]+>/g, ' ').replace(/\\s+/g, ' ').trim().slice(0, 600)\n });\n}\nconst items = Object.keys(byCategory).map((cat) => ({\n json: { category: cat, entries: byCategory[cat] }\n}));\nif (items.length === 0) {\n items.push({ json: { category: null, entries: [] } });\n}\nreturn items;"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "read_digest_prompt",
|
||||||
|
"name": "Read Digest Prompt",
|
||||||
|
"type": "n8n-nodes-base.readWriteFile",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [880, 0],
|
||||||
|
"parameters": {
|
||||||
|
"operation": "read",
|
||||||
|
"fileSelector": "/data/prompts/digest.md",
|
||||||
|
"options": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "build_prompt",
|
||||||
|
"name": "Build Prompt",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [1100, 0],
|
||||||
|
"parameters": {
|
||||||
|
"mode": "runOnceForEachItem",
|
||||||
|
"jsCode": "const systemPrompt = Buffer.from($input.item.binary.data.data, 'base64').toString('utf-8');\nconst category = $('Group By Category').item.json.category;\nconst entries = $('Group By Category').item.json.entries;\nconst init = $('Init').first().json;\nconst schemaReminder = 'Respond with strict JSON only, exactly this shape and no other keys: {\"top_items\": [{\"title\": \"...\", \"url\": \"...\", \"summary\": \"...\"}], \"weak_signals\": [\"...\"]}. No markdown code fences, no extra commentary. Reminder: every \"summary\" and every \"weak_signals\" entry must be written in French, even though the articles below are in English.';\nconst userContent = `Category: ${category}\\n\\nArticles:\\n` + entries.map((e, i) => `${i + 1}. ${e.title}\\nURL: ${e.url}\\nExcerpt: ${e.excerpt}`).join('\\n\\n') + `\\n\\n${schemaReminder}`;\nreturn { json: { category, entries, systemPrompt, userContent, llmBaseUrl: init.llmBaseUrl, llmModel: init.llmModel } };"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "summarize_category",
|
||||||
|
"name": "Summarize Category",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [1320, 0],
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "={{ $json.llmBaseUrl }}/chat/completions",
|
||||||
|
"authentication": "genericCredentialType",
|
||||||
|
"genericAuthType": "httpHeaderAuth",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [{ "name": "Content-Type", "value": "application/json" }]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify({ model: $json.llmModel, messages: [{ role: 'system', content: $json.systemPrompt }, { role: 'user', content: $json.userContent }], response_format: { type: 'json_object' } }) }}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"credentials": {
|
||||||
|
"httpHeaderAuth": { "id": "pZ4g3ReSwgDA6WLP", "name": "LLM API" }
|
||||||
|
},
|
||||||
|
"retryOnFail": true,
|
||||||
|
"maxTries": 3,
|
||||||
|
"waitBetweenTries": 2000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "parse_llm_response",
|
||||||
|
"name": "Parse LLM Response",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [1540, 0],
|
||||||
|
"parameters": {
|
||||||
|
"mode": "runOnceForEachItem",
|
||||||
|
"jsCode": "const category = $('Group By Category').item.json.category;\nlet parsed = null;\ntry {\n const content = $json.choices && $json.choices[0] && $json.choices[0].message && $json.choices[0].message.content;\n parsed = content ? JSON.parse(content) : null;\n} catch (err) {\n parsed = null;\n}\nconst top_items = (parsed && Array.isArray(parsed.top_items)) ? parsed.top_items : [];\nconst weak_signals = (parsed && Array.isArray(parsed.weak_signals)) ? parsed.weak_signals : [];\nreturn { json: { category, top_items, weak_signals } };"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "combine_for_editorial",
|
||||||
|
"name": "Combine For Editorial",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [1760, 0],
|
||||||
|
"parameters": {
|
||||||
|
"mode": "runOnceForAllItems",
|
||||||
|
"jsCode": "const CATEGORY_ORDER = ['Cloud Native', 'Integration', 'APIM', 'EDA', 'AI', 'Security', 'Sovereignty', 'Analysts', 'Others'];\nconst items = $input.all().map((i) => i.json);\nitems.sort((a, b) => {\n const ia = CATEGORY_ORDER.indexOf(a.category);\n const ib = CATEGORY_ORDER.indexOf(b.category);\n return (ia === -1 ? CATEGORY_ORDER.length : ia) - (ib === -1 ? CATEGORY_ORDER.length : ib);\n});\nreturn [{ json: { categories: items } }];"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "read_editorial_prompt",
|
||||||
|
"name": "Read Editorial Prompt",
|
||||||
|
"type": "n8n-nodes-base.readWriteFile",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [1980, 0],
|
||||||
|
"parameters": {
|
||||||
|
"operation": "read",
|
||||||
|
"fileSelector": "/data/prompts/editorial.md",
|
||||||
|
"options": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "build_editorial_prompt",
|
||||||
|
"name": "Build Editorial Prompt",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [2200, 0],
|
||||||
|
"parameters": {
|
||||||
|
"mode": "runOnceForEachItem",
|
||||||
|
"jsCode": "const systemPrompt = Buffer.from($input.item.binary.data.data, 'base64').toString('utf-8');\nconst categories = $('Combine For Editorial').first().json.categories;\nconst slim = categories.map((c) => ({ category: c.category, items: c.top_items.map((it) => ({ title: it.title, summary: it.summary })) })).filter((c) => c.items.length > 0);\nconst init = $('Init').first().json;\nconst schemaReminder = 'Respond with strict JSON only, exactly this shape and no other keys: {\"lead\": \"...\", \"highlights\": [\"...\"]}. No markdown code fences, no extra commentary. Reminder: \"lead\" and every \"highlights\" entry must be written in French. \"lead\" is one short sentence; \"highlights\" is 2 to 5 short one-sentence bullets, not one long paragraph.';\nconst userContent = JSON.stringify(slim) + '\\n\\n' + schemaReminder;\nreturn { json: { systemPrompt, userContent, llmBaseUrl: init.llmBaseUrl, llmModel: init.editorialModel } };"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "editorial_pass",
|
||||||
|
"name": "Editorial Pass (Opus)",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [2420, 0],
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "={{ $json.llmBaseUrl }}/chat/completions",
|
||||||
|
"authentication": "genericCredentialType",
|
||||||
|
"genericAuthType": "httpHeaderAuth",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [{ "name": "Content-Type", "value": "application/json" }]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify({ model: $json.llmModel, messages: [{ role: 'system', content: $json.systemPrompt }, { role: 'user', content: $json.userContent }], response_format: { type: 'json_object' } }) }}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"credentials": {
|
||||||
|
"httpHeaderAuth": { "id": "pZ4g3ReSwgDA6WLP", "name": "LLM API" }
|
||||||
|
},
|
||||||
|
"retryOnFail": true,
|
||||||
|
"maxTries": 3,
|
||||||
|
"waitBetweenTries": 2000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "parse_editorial_response",
|
||||||
|
"name": "Parse Editorial Response",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [2640, 0],
|
||||||
|
"parameters": {
|
||||||
|
"mode": "runOnceForAllItems",
|
||||||
|
"jsCode": "let parsed = null;\ntry {\n const content = $json.choices && $json.choices[0] && $json.choices[0].message && $json.choices[0].message.content;\n parsed = content ? JSON.parse(content) : null;\n} catch (err) {\n parsed = null;\n}\nconst lead = (parsed && typeof parsed.lead === 'string') ? parsed.lead.trim() : '';\nconst highlights = (parsed && Array.isArray(parsed.highlights)) ? parsed.highlights.filter((h) => typeof h === 'string' && h.trim()) : [];\nconst categories = $('Combine For Editorial').first().json.categories;\nreturn [{ json: { categories, lead, highlights } }];"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "compose_digest",
|
||||||
|
"name": "Compose Digest",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [2860, 0],
|
||||||
|
"parameters": {
|
||||||
|
"mode": "runOnceForAllItems",
|
||||||
|
"jsCode": "const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\"/g, '"');\nconst cats = $input.first().json.categories;\nconst lead = $input.first().json.lead || '';\nconst highlights = $input.first().json.highlights || [];\nconst date = $('Init').first().json.digestDate;\nconst header = `# Veille technique — ${date}\\n\\n`;\nlet md = header;\nlet htmlSections = '';\nif (lead || highlights.length) {\n md += `## Analyse éditoriale\\n\\n`;\n htmlSections += `<section class=\"editorial\"><h2>Analyse éditoriale</h2>`;\n if (lead) {\n md += `${lead}\\n\\n`;\n htmlSections += `<p class=\"lead\">${esc(lead)}</p>`;\n }\n if (highlights.length) {\n md += highlights.map((h) => `- ${h}`).join('\\n') + `\\n\\n`;\n htmlSections += `<ul>` + highlights.map((h) => `<li>${esc(h)}</li>`).join('') + `</ul>`;\n }\n htmlSections += `</section>`;\n}\nlet hasContent = false;\nfor (const cat of cats) {\n if (!cat.top_items.length && !cat.weak_signals.length) continue;\n hasContent = true;\n md += `## ${cat.category}\\n\\n`;\n htmlSections += `<section><h2>${esc(cat.category)}</h2><ul>`;\n for (const it of cat.top_items) {\n md += `- **[${it.title}](${it.url})** — ${it.summary}\\n`;\n htmlSections += `<li><a href=\"${esc(it.url)}\">${esc(it.title)}</a> — ${esc(it.summary)}</li>`;\n }\n htmlSections += `</ul>`;\n if (cat.weak_signals.length) {\n md += `\\n_Signaux faibles :_\\n` + cat.weak_signals.map((s) => `- ${s}`).join('\\n') + `\\n`;\n htmlSections += `<p class=\"weak-label\">Signaux faibles</p><ul class=\"weak\">` + cat.weak_signals.map((s) => `<li>${esc(s)}</li>`).join('') + `</ul>`;\n }\n md += `\\n`;\n htmlSections += `</section>`;\n}\nif (!hasContent) {\n md += `_Aucun article pertinent aujourd'hui._\\n`;\n htmlSections += `<p>Aucun article pertinent aujourd'hui.</p>`;\n}\nconst html = `<!doctype html>\n<html lang=\"fr\"><head><meta charset=\"utf-8\">\n<title>Veille technique — ${esc(date)}</title>\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<style>\nbody { font-family: -apple-system, Segoe UI, Helvetica, Arial, sans-serif; max-width: 46rem; margin: 2rem auto; padding: 0 1rem; line-height: 1.5; color: #1c1c1c; }\nh1 { font-size: 1.6rem; border-bottom: 2px solid #222; padding-bottom: 0.4rem; }\nh2 { font-size: 1.15rem; margin-top: 2rem; color: #333; }\nul { padding-left: 1.2rem; }\nli { margin-bottom: 0.6rem; }\na { color: #0b5fae; text-decoration: none; }\na:hover { text-decoration: underline; }\n.weak-label { font-size: 0.85rem; color: #666; font-style: italic; margin: 0.6rem 0 0.2rem; }\nul.weak { padding-left: 1.2rem; margin-top: 0; }\nul.weak li { font-size: 0.85rem; color: #666; font-style: italic; margin-bottom: 0.3rem; }\n.editorial { background: #f4f6fb; border-left: 4px solid #0b5fae; padding: 0.8rem 1.2rem; border-radius: 4px; }\n.editorial h2 { margin-top: 0; }\n.editorial p.lead { font-weight: 600; margin-bottom: 0.6rem; }\n.editorial ul { margin-bottom: 0; }\n.editorial ul li { margin-bottom: 0.4rem; }\n</style></head>\n<body>\n<h1>Veille technique — ${esc(date)}</h1>\n${htmlSections}\n</body></html>`;\nconst fileNameMd = `digest-${date}.md`;\nconst fileNameHtml = `digest-${date}.html`;\nconst mdBinary = await this.helpers.prepareBinaryData(Buffer.from(md, 'utf-8'), fileNameMd, 'text/markdown');\nconst htmlBinary = await this.helpers.prepareBinaryData(Buffer.from(html, 'utf-8'), fileNameHtml, 'text/html');\nreturn [{ json: { markdown: md, fileNameMd, fileNameHtml }, binary: { md: mdBinary, html: htmlBinary } }];"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "write_digest_file",
|
||||||
|
"name": "Write Digest File (Markdown)",
|
||||||
|
"type": "n8n-nodes-base.readWriteFile",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [3080, -160],
|
||||||
|
"parameters": {
|
||||||
|
"operation": "write",
|
||||||
|
"fileName": "=/data/digests/{{ $json.fileNameMd }}",
|
||||||
|
"dataPropertyName": "md",
|
||||||
|
"options": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "write_digest_file_html",
|
||||||
|
"name": "Write Digest File (HTML)",
|
||||||
|
"type": "n8n-nodes-base.readWriteFile",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [3080, -40],
|
||||||
|
"parameters": {
|
||||||
|
"operation": "write",
|
||||||
|
"fileName": "=/data/digests/{{ $json.fileNameHtml }}",
|
||||||
|
"dataPropertyName": "html",
|
||||||
|
"options": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "collect_entry_ids",
|
||||||
|
"name": "Collect Entry IDs",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [3300, -100],
|
||||||
|
"parameters": {
|
||||||
|
"mode": "runOnceForAllItems",
|
||||||
|
"jsCode": "const entries = $('Get Unread Entries').first().json.entries || [];\nconst entryIds = entries.map((e) => e.id);\nif (entryIds.length === 0) return [];\nreturn [{ json: { entryIds } }];"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mark_entries_as_read",
|
||||||
|
"name": "Mark Entries As Read",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [3520, -100],
|
||||||
|
"parameters": {
|
||||||
|
"method": "PUT",
|
||||||
|
"url": "http://miniflux.watch.svc.cluster.local/v1/entries",
|
||||||
|
"authentication": "genericCredentialType",
|
||||||
|
"genericAuthType": "httpHeaderAuth",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [{ "name": "Content-Type", "value": "application/json" }]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify({ entry_ids: $json.entryIds, status: 'read' }) }}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"credentials": {
|
||||||
|
"httpHeaderAuth": { "id": "D3czTdAQl3KhiKTM", "name": "Miniflux API" }
|
||||||
|
},
|
||||||
|
"retryOnFail": true,
|
||||||
|
"maxTries": 3,
|
||||||
|
"waitBetweenTries": 2000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Schedule Trigger": { "main": [[{ "node": "Init", "type": "main", "index": 0 }]] },
|
||||||
|
"Init": { "main": [[{ "node": "Get Unread Entries", "type": "main", "index": 0 }]] },
|
||||||
|
"Get Unread Entries": { "main": [[{ "node": "Group By Category", "type": "main", "index": 0 }]] },
|
||||||
|
"Group By Category": { "main": [[{ "node": "Read Digest Prompt", "type": "main", "index": 0 }]] },
|
||||||
|
"Read Digest Prompt": { "main": [[{ "node": "Build Prompt", "type": "main", "index": 0 }]] },
|
||||||
|
"Build Prompt": { "main": [[{ "node": "Summarize Category", "type": "main", "index": 0 }]] },
|
||||||
|
"Summarize Category": { "main": [[{ "node": "Parse LLM Response", "type": "main", "index": 0 }]] },
|
||||||
|
"Parse LLM Response": { "main": [[{ "node": "Combine For Editorial", "type": "main", "index": 0 }]] },
|
||||||
|
"Combine For Editorial": { "main": [[{ "node": "Read Editorial Prompt", "type": "main", "index": 0 }]] },
|
||||||
|
"Read Editorial Prompt": { "main": [[{ "node": "Build Editorial Prompt", "type": "main", "index": 0 }]] },
|
||||||
|
"Build Editorial Prompt": { "main": [[{ "node": "Editorial Pass (Opus)", "type": "main", "index": 0 }]] },
|
||||||
|
"Editorial Pass (Opus)": { "main": [[{ "node": "Parse Editorial Response", "type": "main", "index": 0 }]] },
|
||||||
|
"Parse Editorial Response": { "main": [[{ "node": "Compose Digest", "type": "main", "index": 0 }]] },
|
||||||
|
"Compose Digest": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{ "node": "Write Digest File (Markdown)", "type": "main", "index": 0 },
|
||||||
|
{ "node": "Write Digest File (HTML)", "type": "main", "index": 0 }
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Write Digest File (Markdown)": { "main": [[{ "node": "Collect Entry IDs", "type": "main", "index": 0 }]] },
|
||||||
|
"Collect Entry IDs": { "main": [[{ "node": "Mark Entries As Read", "type": "main", "index": 0 }]] }
|
||||||
|
},
|
||||||
|
"settings": { "executionOrder": "v1", "timezone": "Europe/Paris" }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user