diff --git a/.github/workflows/04.1.agentic_v2_publish.yml b/.github/workflows/04.1.agentic_v2_publish.yml
index 5f658ed0..f505ea98 100644
--- a/.github/workflows/04.1.agentic_v2_publish.yml
+++ b/.github/workflows/04.1.agentic_v2_publish.yml
@@ -93,6 +93,7 @@ jobs:
GEMINI_API_KEY_2: ${{ secrets.GEMINI_API_KEY_2 }}
run: |
python -u -m src.news_digest || echo "News digest generation skipped (no API key or error)"
+ python -u -m src.rss_generator || echo "RSS generation skipped"
- name: Run V2 Publisher (Render-Only)
env:
diff --git a/.github/workflows/09.weekly_digest.yml b/.github/workflows/09.weekly_digest.yml
new file mode 100644
index 00000000..6931c41d
--- /dev/null
+++ b/.github/workflows/09.weekly_digest.yml
@@ -0,0 +1,70 @@
+name: 09. Weekly Intelligence Digest
+
+on:
+ schedule:
+ # Every Monday at 06:00 UTC (08:00 Madrid time)
+ - cron: '0 6 * * 1'
+ workflow_dispatch:
+
+permissions:
+ contents: write
+ pull-requests: write
+
+concurrency:
+ group: develop-git-write-lock
+ cancel-in-progress: false
+
+jobs:
+ weekly-digest:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Repository Synchronization
+ uses: actions/checkout@v6
+ with:
+ ref: develop
+ fetch-depth: 0
+
+ - name: Python 3.11 Environment Provisioning
+ uses: actions/setup-python@v6
+ with:
+ python-version: '3.11'
+ cache: 'pip'
+
+ - name: Install Dependencies
+ run: pip install -r requirements.txt
+
+ - name: Generate News Digest (Gemini)
+ env:
+ PYTHONPATH: ${{ github.workspace }}
+ GEMINI_API_KEY_1: ${{ secrets.GEMINI_API_KEY_1 }}
+ GEMINI_API_KEY_2: ${{ secrets.GEMINI_API_KEY_2 }}
+ run: |
+ python -u -m src.news_digest
+ python -u -m src.rss_generator || echo "RSS generation skipped"
+
+ - name: Render V2 Portal (Digest Pages Only)
+ env:
+ PYTHONPATH: ${{ github.workspace }}
+ PYTHONUNBUFFERED: "1"
+ run: |
+ python -u -m src.v2_optimizer --render-only
+
+ - name: Commit and Push Weekly Digest
+ run: |
+ git config --global user.name "Nubenetes Bot"
+ git config --global user.email "bot@nubenetes.com"
+ git add data/news_digest.json v2-docs/tech-digest.md v2-docs/industry-digest.md v2-docs/feed.xml || true
+ if git diff --staged --quiet; then
+ echo "No digest changes to commit."
+ else
+ git commit -m "feat: weekly intelligence digest update [skip ci]"
+ for i in {1..3}; do
+ git pull origin develop --rebase && git push origin develop && break || sleep 10
+ done
+ fi
+
+ - name: Trigger V2 Publisher
+ uses: peter-evans/repository-dispatch@v3
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ event-type: weekly-digest-ready
diff --git a/docs/static/v2_elite.css b/docs/static/v2_elite.css
index 16321c5d..e2155020 100644
--- a/docs/static/v2_elite.css
+++ b/docs/static/v2_elite.css
@@ -1043,6 +1043,18 @@ input[type="text"] {
display: flex;
align-items: center;
gap: 8px;
+ flex-wrap: wrap;
+}
+
+.trending-section__updated {
+ font-size: 0.55em;
+ font-weight: 500;
+ padding: 2px 10px;
+ border-radius: 20px;
+ background: rgba(14, 165, 233, 0.12);
+ color: var(--md-accent-fg-color);
+ border: 1px solid rgba(14, 165, 233, 0.25);
+ letter-spacing: 0.02em;
}
.trending-grid {
diff --git a/src/news_digest.py b/src/news_digest.py
index c45d78e8..a6ec756f 100644
--- a/src/news_digest.py
+++ b/src/news_digest.py
@@ -182,11 +182,38 @@ class NewsDigestEngine:
return None
def _get_entry_geo(self, entry: dict) -> str | None:
- """Return the geo digest category if ``geo_region`` matches."""
+ """Return the geo digest category using geo_region field, falling back to URL TLD inference."""
region = entry.get("geo_region", "")
for geo_name, geo_val in self.GEO_CATEGORIES.items():
if region == geo_val:
return geo_name
+ # Fallback: infer from URL TLD
+ return self._infer_geo_from_url(entry.get("url", ""))
+
+ @staticmethod
+ def _infer_geo_from_url(url: str) -> str | None:
+ """Infer geo category from URL TLD. Returns GEO_CATEGORIES key or None."""
+ try:
+ from urllib.parse import urlparse
+ host = urlparse(url).hostname or ""
+ # Ordered longest-first to avoid .uk matching before .co.uk
+ tld_to_region = [
+ (".com.au", "Asia-Pacific"), (".co.uk", "Europe"), (".co.jp", "Asia-Pacific"),
+ (".co.kr", "Asia-Pacific"), (".com.br", "Americas"), (".com.mx", "Americas"),
+ (".es", "EspaΓ±a"), (".de", "Europe"), (".fr", "Europe"), (".it", "Europe"),
+ (".pt", "Europe"), (".nl", "Europe"), (".be", "Europe"), (".se", "Europe"),
+ (".dk", "Europe"), (".fi", "Europe"), (".no", "Europe"), (".ch", "Europe"),
+ (".at", "Europe"), (".pl", "Europe"), (".cz", "Europe"), (".uk", "Europe"),
+ (".ie", "Europe"), (".eu", "Europe"), (".cn", "Asia-Pacific"),
+ (".jp", "Asia-Pacific"), (".kr", "Asia-Pacific"), (".sg", "Asia-Pacific"),
+ (".in", "Asia-Pacific"), (".au", "Asia-Pacific"), (".nz", "Asia-Pacific"),
+ (".ca", "Americas"), (".mx", "Americas"), (".br", "Americas"),
+ ]
+ for tld, region in tld_to_region:
+ if host.endswith(tld):
+ return region
+ except Exception:
+ pass
return None
@staticmethod
diff --git a/src/rss_generator.py b/src/rss_generator.py
new file mode 100644
index 00000000..6482c68e
--- /dev/null
+++ b/src/rss_generator.py
@@ -0,0 +1,111 @@
+"""RSS 2.0 feed generator for the Nubenetes Intelligence Digest.
+
+Reads data/news_digest.json and writes v2-docs/feed.xml with the top
+items from the 3-month digest window across all tech categories.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+from datetime import datetime
+from email.utils import format_datetime
+from xml.sax.saxutils import escape
+
+from src.logger import log_event
+
+DIGEST_PATH = "data/news_digest.json"
+OUTPUT_PATH = "v2-docs/feed.xml"
+FEED_TITLE = "Nubenetes Intelligence Digest"
+FEED_LINK = "https://nubenetes.com/"
+FEED_DESCRIPTION = "AI-curated top picks from the Cloud Native & Kubernetes ecosystem"
+FEED_LANGUAGE = "en"
+ITEMS_PER_FEED = 20
+
+TECH_CATS = [
+ "Kubernetes & Orchestration", "AI & Agents", "Security & Compliance",
+ "CI/CD & GitOps", "Observability, SRE & Testing", "Infrastructure as Code",
+ "Containers & Runtime", "Networking & Service Mesh", "Cloud Providers & FinOps",
+ "MLOps & Data Science", "Data, Messaging & Storage",
+]
+
+
+def _rfc822(dt: datetime) -> str:
+ return format_datetime(dt)
+
+
+def generate_rss() -> None:
+ if not os.path.exists(DIGEST_PATH):
+ log_event("[WARN] rss_generator: news_digest.json not found, skipping RSS generation")
+ return
+
+ try:
+ with open(DIGEST_PATH, "r", encoding="utf-8") as f:
+ digest = json.load(f)
+ except Exception as e:
+ log_event(f"[WARN] rss_generator: failed to load digest: {str(e)[:100]}")
+ return
+
+ period_data = digest.get("3_months", {})
+ items: list[dict] = []
+
+ for cat in TECH_CATS:
+ for entry in period_data.get(cat, []):
+ items.append({**entry, "_cat": cat})
+
+ # Sort by impact then date
+ impact_rank = {"critical": 3, "high": 2, "medium": 1}
+ items.sort(
+ key=lambda x: (impact_rank.get(x.get("impact", "medium"), 0), x.get("date", "")),
+ reverse=True,
+ )
+ items = items[:ITEMS_PER_FEED]
+
+ build_date = _rfc822(datetime.utcnow())
+
+ lines = [
+ '',
+ '