Compare commits

...
52 Commits
Author SHA1 Message Date
Nubenetes Bot cc6633dcdd release: v2.8.2 — fix all None-stars sort crashes in v2_optimizer 2026-06-19 12:12:58 +02:00
Inaki 759c10ad2c Merge pull request #365 from nubenetes/feat/fix-stars-none-sort
fix: all negated .get(stars) sort keys crash on null values
2026-06-19 12:12:46 +02:00
Nubenetes BotandClaude Sonnet 4.6 58601de338 fix: guard all negated .get('stars') sort keys against None values
Three sites in v2_optimizer where `-x.get("stars", default)` crashes
with TypeError when stars field is present but null in inventory.
Fixed with `-(x.get("stars") or default)` pattern consistently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 12:12:39 +02:00
Nubenetes Bot 11ebda6187 docs: automated README metric synchronization [skip ci] 2026-06-19 09:43:30 +00:00
Nubenetes Bot 227dc23933 merge: back-merge v2.8.1 into develop 2026-06-19 11:43:22 +02:00
Nubenetes Bot 56db4d5e53 release: v2.8.1 — fix None resource_type crash in v2_optimizer 2026-06-19 11:43:18 +02:00
Inaki e332af2ea8 Merge pull request #364 from nubenetes/feat/fix-resource-type-none
fix: None resource_type crash in _calculate_tags
2026-06-19 11:43:05 +02:00
Nubenetes BotandClaude Sonnet 4.6 9d3321d0f0 fix: guard against None resource_type in _calculate_tags
item.get("resource_type", "Reference") returns None when the field
exists with a null value in the inventory. Use `or "Reference"` instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 11:42:58 +02:00
Nubenetes Bot 9ed1318a9c merge: back-merge v2.8.0 into develop 2026-06-19 11:12:21 +02:00
Nubenetes Bot 91da9c8495 release: v2.8.0 — geo inference, last-updated badge, search boost, RSS feed, weekly cron 2026-06-19 11:12:16 +02:00
Nubenetes Bot c9a7b2a8cb docs: automated README metric synchronization [skip ci] 2026-06-19 09:11:47 +00:00
Inaki ad59cb75f0 Merge pull request #363 from nubenetes/feat/five-improvements
feat: geo inference, last-updated badge, search boost, RSS feed, weekly cron
2026-06-19 11:11:32 +02:00
Nubenetes BotandClaude Sonnet 4.6 88f891fd4e feat: 5 improvements — geo inference, last-updated badge, search boost, RSS feed, weekly cron
1. geo_region inference: _infer_geo_from_url() infers Americas/Europe/España/Asia-Pacific
   from URL TLD (.es .de .fr .uk .jp .cn etc.) as fallback when geo_region field is empty.
   Industry digest now shows real content instead of empty categories.

2. Last-updated badge: trending section header now shows "Updated Jun 19, 2026" pill
   derived from news_digest.json mtime — gives readers confidence in freshness.

3. Search boost: tech-digest and industry-digest pages now have search.boost: 2
   frontmatter so they rank higher in MkDocs Material site search.

4. RSS feed (src/rss_generator.py): generates v2-docs/feed.xml with top-20 curated
   picks from 3-month digest. Runs after news_digest in publisher. Autodiscovery
   <link rel="alternate"> added to v2-mkdocs.yml extra_head.

5. Weekly cron (09.weekly_digest.yml): runs every Monday 06:00 UTC, generates digest
   with Gemini, renders digest pages, commits, then triggers publisher.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 11:11:02 +02:00
Nubenetes Bot 61733937be merge: back-merge v2.7.1 into develop 2026-06-19 11:06:42 +02:00
Nubenetes Bot 10c70025a2 release: v2.7.1 — remove redundant digest-preview list from index 2026-06-19 11:06:38 +02:00
Inaki cff4903166 Merge pull request #362 from nubenetes/feat/remove-digest-preview-from-index
refactor: remove digest-preview list from index
2026-06-19 11:05:54 +02:00
Nubenetes BotandClaude Sonnet 4.6 16ad26f427 refactor: remove digest-preview list from index, keep trending cards only
The 5-item link list was redundant with the 6 trending cards block
that the publisher generates. Cleaner UX: hero card (amber) → trending
cards → /tech-digest/ for full 22-category view.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 11:05:41 +02:00
Nubenetes Bot 3f3a86b62c merge: back-merge master v2.7.0 into develop 2026-06-19 10:59:38 +02:00
Nubenetes Bot 239dd05e6f release: v2.7.0 — Intelligence Digest card and mini-preview on index 2026-06-19 10:59:31 +02:00
Inaki 932770b89b Merge pull request #361 from nubenetes/feat/digest-card-index
feat: Intelligence Digest hero card and mini-preview on index
2026-06-19 10:59:15 +02:00
Nubenetes BotandClaude Sonnet 4.6 c8ca878f66 feat: add Intelligence Digest card to index hero and mini-preview block
- New hero card (amber color) between AI & MCP Agents and Agentic Video
  Hub, linking to /tech-digest/ with emoji icon and subtitle
- Dynamic digest-preview block above Agentic Pulse: shows top 1 link
  from 5 priority categories (Kubernetes, AI, Security, IaC, Observability)
  pulled from data/news_digest.json on each optimizer run
- New CSS: hero-badge-card--amber, hero-badge-icon, digest-preview
  component with category chips and hover states
- v2_optimizer now generates both the card and preview automatically

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 10:58:52 +02:00
Nubenetes Bot 64c6e9ea18 Merge branch 'develop' of github.com:nubenetes/awesome-kubernetes into develop 2026-06-19 10:44:46 +02:00
Nubenetes Bot 53b19f1b09 merge: back-merge master v2.6.9 into develop 2026-06-19 10:44:36 +02:00
Nubenetes Bot f6e60a6487 docs: automated README metric synchronization [skip ci] 2026-06-19 08:44:35 +00:00
Nubenetes Bot 5bfcaf1b03 release: v2.6.9 — Fix NoneType crashes in v2_optimizer, dedup, and news_digest 2026-06-19 10:44:32 +02:00
Inaki b8144d9253 Merge pull request #360 from nubenetes/feat/fix-nonetype-comparisons
fix: NoneType crashes in v2_optimizer, dedup, and news_digest
2026-06-19 10:44:13 +02:00
Nubenetes BotandClaude Sonnet 4.6 b321169459 fix: NoneType comparison crashes in v2_optimizer, dedup, and news_digest
Inventory entries can have stars=null or discovered_at=null. Replace
.get("stars", 0) with .get("stars") or 0 in all three modules so that
None values are safely coerced to 0/"" before numeric/string comparison.

Fixes TypeError crashes in:
- v2_optimizer.py:632 _calculate_tags
- dedup.py:74,108 title dedup and entry scoring
- news_digest.py:322 category pool sorting

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 10:44:00 +02:00
Nubenetes Bot 34610f4671 merge: back-merge master v2.6.8 into develop 2026-06-19 10:37:06 +02:00
Nubenetes Bot f50fdffd64 release: v2.6.8 — Fix unbounded license check causing CI timeout 2026-06-19 10:37:01 +02:00
Inaki f5fae99a38 Merge pull request #359 from nubenetes/feat/fix-license-check-limit
fix: cap license check at 200 repos — was unbounded causing CI timeout
2026-06-19 10:36:46 +02:00
Nubenetes BotandClaude Sonnet 4.6 ebae3c6e27 fix: cap license change detection at MAX_REPOS_DEFAULT (200) — was unbounded
detect_license_changes() iterated all repos with gh_license set, causing
hundreds/thousands of GitHub API calls. Apply the same 200-repo cap as
the activity enrichment module to keep total enrichment under ~3 min.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 10:36:33 +02:00
Nubenetes Bot a8941afd24 merge: back-merge master v2.6.7 into develop 2026-06-19 10:28:44 +02:00
Nubenetes Bot f4fb5fe19b release: v2.6.7 — Reduce enrichment CI time from 12min to ~2min 2026-06-19 10:28:39 +02:00
Inaki f46886abf3 Merge pull request #358 from nubenetes/feat/fix-enrichment-speed
perf: reduce enrichment from 12min to ~2min
2026-06-19 10:28:22 +02:00
Nubenetes BotandClaude Sonnet 4.6 6ccda021e2 perf: reduce enrichment from 12min to ~2min (1 API call/repo, 200 limit)
- Consolidate 2 GitHub API calls per repo into 1: open_issues_count
  already includes PRs on GitHub, second /pulls call was redundant
- Reduce MAX_REPOS_DEFAULT 500→200: sufficient for meaningful enrichment,
  200 × 0.5s = ~100s vs 500 × 1.5s = ~12.5min in CI
- Reduce GITHUB_RATE_DELAY 0.75s→0.5s: still safely under 5000 req/hr
- Add 429 rate-limit backoff (5s sleep instead of crashing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 10:28:07 +02:00
Nubenetes Bot 3f44948119 merge: back-merge master v2.6.6 into develop 2026-06-19 10:16:41 +02:00
Nubenetes Bot fe024b4fd7 release: v2.6.6 — Fix CNCF landscape API (SPA→GitHub topic search) 2026-06-19 10:16:37 +02:00
Inaki bdcedd6950 Merge pull request #357 from nubenetes/feat/fix-cncf-api
fix: CNCF landscape API now SPA — use GitHub topic search instead
2026-06-19 10:16:20 +02:00
Nubenetes BotandClaude Sonnet 4.6 6769fff528 fix: replace CNCF landscape SPA endpoint with GitHub topic search
The legacy landscape.cncf.io/api/items endpoint now returns HTML (SPA)
instead of JSON. Switch to GitHub Search API querying cncf-graduated,
cncf-incubating, and cncf-sandbox topics — reliable and uses existing
GH_TOKEN auth.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 10:16:07 +02:00
Nubenetes Bot 5b00ed4824 merge: back-merge master v2.6.5 into develop 2026-06-19 09:51:32 +02:00
Nubenetes Bot 7e85ec6bdd release: v2.6.5 — Fix MD023 linter errors in digest pages 2026-06-19 09:51:27 +02:00
Inaki dc0e090318 Merge pull request #356 from nubenetes/feat/fix-digest-linter
fix: MD023 lint — replace indented headings with bold in digest tabs
2026-06-19 09:51:08 +02:00
Nubenetes BotandClaude Sonnet 4.6 6f34ac8569 fix: replace indented ## headings with bold text in digest pages (MD023 lint fix)
- Change `    ## Category` to `    **Category**` inside MkDocs Material
  tabs to avoid MD023 linter errors (headings must start at col 0)
- Sanitize pipe characters in "why" text to prevent broken markdown tables
- Regenerated tech-digest.md (1328 lines, 3 tabs: 333/443/552 lines)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 09:50:53 +02:00
Nubenetes Bot 5d7f58c7fa merge: back-merge master v2.6.4 into develop 2026-06-19 09:45:22 +02:00
Nubenetes Bot 52e2560fa0 release: v2.6.4 — Fix digest periods differentiation 2026-06-19 09:45:18 +02:00
Inaki 41207ca0f0 Merge pull request #355 from nubenetes/feat/fix-digest-periods
fix: differentiate digest periods and fix NoneType stars
2026-06-19 09:44:59 +02:00
Nubenetes BotandClaude Sonnet 4.6 b5b3dc3ce2 fix: differentiate digest periods (10/15/20 items), fix year fallback, fix NoneType stars
- Variable items per period: 3 months=10, 6 months=15, 12 months=20
  so each tab shows progressively more content
- Use year field as fallback in _is_within_period when discovered_at
  doesn't discriminate (backfilled entries)
- Fix NoneType comparison in _fallback_items for entries with null stars
- Regenerated digest: 3m=220, 6m=330, 12m=440 items across 22 categories
- tech-digest.md now 1353 lines with differentiated tabs (339/449/559 lines)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 09:44:44 +02:00
Nubenetes Bot f221647646 merge: back-merge master v2.6.3 into develop 2026-06-19 09:33:18 +02:00
Nubenetes Bot 099a40d6b1 release: v2.6.3 — First Intelligence Digest with real content 2026-06-19 09:33:13 +02:00
Inaki 74cbc47e2e Merge pull request #354 from nubenetes/feat/generate-digest-content
feat: first Intelligence Digest with real content (22 categories)
2026-06-19 09:32:38 +02:00
Nubenetes BotandClaude Sonnet 4.6 f90d66478f feat: generate first Intelligence Digest with real content (22 categories, 660 items)
- Generate news_digest.json from inventory using star-based ranking
  (no Gemini tokens consumed — fallback mode)
- tech-digest.md: 1022 lines with 22 categories across 3/6/12 month tabs
- industry-digest.md: placeholder (geo_region data will populate after
  next curator run with updated Gemini prompts)
- Add gitignore exception for data/news_digest.json
- 220 items per time period across Kubernetes, AI, DevOps, Security,
  IaC, Observability, Cloud Providers, and more

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 09:32:23 +02:00
Nubenetes Bot d846c6f336 merge: back-merge master v2.6.2 into develop 2026-06-19 09:29:29 +02:00
15 changed files with 10789 additions and 108 deletions
@@ -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:
+70
View File
@@ -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
+1
View File
@@ -351,6 +351,7 @@ MigrationBackup/
# Automatización Nubenetes
src/__pycache__/
*.json
!data/news_digest.json
.env
nubenetes_agent_env/
.venv/
+6 -6
View File
@@ -135,14 +135,14 @@ Additionally, as of May 2026, Nubenetes has reached the **Platinum Operational T
## 2. Repository Metrics and Evolution
### 2.1. The "Heart" of Nubenetes
(Stats as of 2026-06-18)
(Stats as of 2026-06-19)
<!-- HEART_STATS_START -->
| Metric | Value |
| :--- | :--- |
| **Total Technical Resources (Links)** | **18647+** |
| **Specialized MD Pages** | **162** |
| **Total Commits** | **6013+** |
| **Total Commits** | **6077+** |
| **Primary AI Engine** | **Google Gemini (Agentic)** |
<!-- HEART_STATS_END -->
@@ -180,7 +180,7 @@ The growth of Nubenetes reflects the acceleration of the Cloud Native ecosystem.
| 6 | 2023 | 30 | 123 | Maintenance & Refinement |
| 7 | 2024 | 53 | 218 | Curation Strategy Pivot |
| 8 | 2025 | 5 | 20 | Stability & Research Phase |
| 9 | 2026 | 2454 | 10,135 | **Agentic AI Surge** (May 2026 Inception) |
| 9 | 2026 | 2518 | 10,399 | **Agentic AI Surge** (May 2026 Inception) |
<!-- ANNUAL_GROWTH_END -->
<!-- ANNUAL_CHART_START -->
@@ -196,8 +196,8 @@ xychart-beta
title "Nubenetes Annual Growth Metrics (20182026)"
x-axis ["2018", "2019", "2020", "2021", "2022", "2023", "2024", "2025", "2026"]
y-axis "Volume (Commits / Estimated New Refs)" 0 --> 11000
bar [1445, 586, 8449, 2193, 1660, 123, 218, 20, 10135]
bar [350, 142, 2046, 531, 402, 30, 53, 5, 2454]
bar [1445, 586, 8449, 2193, 1660, 123, 218, 20, 10399]
bar [350, 142, 2046, 531, 402, 30, 53, 5, 2518]
```
<!-- ANNUAL_CHART_END -->
@@ -207,7 +207,7 @@ xychart-beta
| :--- | :---: | :---: | :--- |
| 2026-04 | 25 | 103 | Active Curation |
| 2026-05 | 2101 | 8,677 | **Agentic Inception (Gemini Era)** |
| 2026-06 | 328 | 1,354 | Active Curation |
| 2026-06 | 392 | 1,618 | Active Curation |
<!-- MONTHLY_SURGE_END -->
### 2.4. Content Distribution and Semantic Clustering
File diff suppressed because it is too large Load Diff
+78
View File
@@ -352,6 +352,22 @@ a {
border-color: #2dd4bf !important;
}
.hero-badge-card--amber {
border-color: rgba(245, 158, 11, 0.2);
background: rgba(245, 158, 11, 0.02);
}
.hero-badge-card--amber:hover {
background: rgba(245, 158, 11, 0.06) !important;
box-shadow: 0 8px 24px rgba(245, 158, 11, 0.18);
border-color: #f59e0b !important;
}
.hero-badge-icon {
font-size: 2rem;
line-height: 1;
margin-bottom: 4px;
}
.hero-badge-title {
font-weight: bold;
font-size: 0.95rem;
@@ -364,6 +380,56 @@ a {
margin-top: 4px;
}
/* Intelligence Digest mini-preview on index */
.digest-preview {
background: rgba(245, 158, 11, 0.04);
border: 1px solid rgba(245, 158, 11, 0.15);
border-radius: 12px;
padding: 20px 24px;
margin: 24px 0;
}
.digest-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.digest-preview-title {
font-weight: 700;
font-size: 1rem;
color: var(--md-primary-fg-color);
}
.digest-preview-link {
font-size: 0.8rem;
color: #f59e0b;
text-decoration: none;
font-weight: 600;
letter-spacing: 0.02em;
}
.digest-preview-link:hover { text-decoration: underline; }
.digest-preview-list {
list-style: none;
margin: 0;
padding: 0;
}
.digest-preview-list li {
padding: 5px 0;
border-bottom: 1px solid rgba(245, 158, 11, 0.08);
font-size: 0.875rem;
display: flex;
align-items: center;
gap: 8px;
}
.digest-preview-list li:last-child { border-bottom: none; }
.digest-preview-cat {
font-size: 0.7rem;
color: #f59e0b;
background: rgba(245, 158, 11, 0.1);
border-radius: 4px;
padding: 1px 6px;
white-space: nowrap;
}
/* Hero Showcase Image wrapper (4 cars in a container) */
.hero-showcase-wrapper {
margin: 24px auto;
@@ -977,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 {
+2 -2
View File
@@ -71,7 +71,7 @@ def find_title_duplicates(inventory: Dict, threshold: float = 0.85) -> List[Tupl
norm = normalize_title(title)
if len(norm) < 10:
continue
entries.append((url, norm, entry.get("stars", 0)))
entries.append((url, norm, entry.get("stars") or 0))
log_event(f"[Dedup] Building title index for {len(entries)} entries...")
@@ -105,7 +105,7 @@ def find_title_duplicates(inventory: Dict, threshold: float = 0.85) -> List[Tupl
def _entry_score(entry: Dict) -> Tuple:
return (
entry.get("stars", 0),
entry.get("stars") or 0,
1 if entry.get("ai_summary") else 0,
1 if entry.get("hierarchy") else 0,
len(entry.get("tags", [])),
+39 -61
View File
@@ -13,10 +13,10 @@ from src.logger import log_event
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
CNCF_LANDSCAPE_URL = "https://landscape.cncf.io/api/items"
CNCF_LANDSCAPE_URL = "https://landscape.cncf.io/api/items" # Legacy, now SPA — fallback to GitHub topic search
GITHUB_API_BASE = "https://api.github.com"
GITHUB_RATE_DELAY = 0.75 # seconds between GitHub API calls to stay under 5000/hr
MAX_REPOS_DEFAULT = 500
GITHUB_RATE_DELAY = 0.5 # seconds between GitHub API calls (5000/hr limit = ~1.4/s safe)
MAX_REPOS_DEFAULT = 200 # cap per run — 200 × 0.5s = ~100s, well within CI timeout
ACTIVITY_STALENESS_DAYS = 30
# Community health thresholds
@@ -74,38 +74,37 @@ def _is_activity_stale(entry: Dict) -> bool:
# ---------------------------------------------------------------------------
async def fetch_cncf_landscape() -> Dict[str, str]:
"""Fetch CNCF project graduation status.
"""Fetch CNCF project graduation status via GitHub topic search.
Returns dict mapping repo_url (normalized) -> maturity
("sandbox" | "incubating" | "graduated" | "archived").
The legacy landscape.cncf.io/api/items endpoint is now a SPA and no
longer returns JSON. Instead, we search GitHub for repos with CNCF
maturity topics (cncf-sandbox, cncf-incubating, cncf-graduated).
Returns dict mapping repo_url (normalized) -> maturity.
"""
result: Dict[str, str] = {}
headers = _github_headers()
maturity_queries = {
"graduated": "topic:cncf-graduated",
"incubating": "topic:cncf-incubating",
"sandbox": "topic:cncf-sandbox",
}
async with httpx.AsyncClient(timeout=30.0) as client:
try:
resp = await client.get(CNCF_LANDSCAPE_URL)
resp.raise_for_status()
items = resp.json()
except Exception as e:
log_event(f"[WARN] Failed to fetch CNCF landscape: {str(e)[:200]}")
return result
if not isinstance(items, list):
log_event("[WARN] CNCF landscape response is not a list; skipping")
return result
for item in items:
repo_url = item.get("repo_url") or ""
project = item.get("project")
if not repo_url or not project:
continue
maturity = project if isinstance(project, str) else project.get("maturity", "")
if not maturity:
continue
maturity = maturity.lower()
if maturity not in ("sandbox", "incubating", "graduated", "archived"):
continue
normalized = _normalize_repo_url(repo_url)
result[normalized] = maturity
for maturity, query in maturity_queries.items():
try:
url = f"{GITHUB_API_BASE}/search/repositories?q={query}&per_page=100"
resp = await client.get(url, headers=headers)
resp.raise_for_status()
data = resp.json()
for repo in data.get("items", []):
repo_url = repo.get("html_url", "")
if repo_url:
result[_normalize_repo_url(repo_url)] = maturity
log_event(f" [CNCF] {maturity}: {len(data.get('items', []))} repos found")
except Exception as e:
log_event(f"[WARN] CNCF {maturity} search failed: {str(e)[:100]}")
await asyncio.sleep(GITHUB_RATE_DELAY)
log_event(f"[CNCF] Fetched {len(result)} projects from CNCF landscape")
return result
@@ -156,15 +155,15 @@ async def _fetch_repo_activity(
client: httpx.AsyncClient,
owner_repo: str,
) -> Tuple[int, int]:
"""Fetch open issue count and recent open PR count for a single repo.
"""Fetch open issue + PR count for a single repo in one API call.
Returns (open_issues_count, open_prs_count).
GitHub's open_issues_count includes PRs, so a single /repos endpoint
call is sufficient. Returns (open_issues_count, 0) the caller uses
the combined metric for health classification.
"""
headers = _github_headers()
open_issues = 0
open_prs = 0
# Fetch repo-level stats (open_issues_count includes PRs on GitHub)
try:
resp = await client.get(
f"{GITHUB_API_BASE}/repos/{owner_repo}",
@@ -174,37 +173,15 @@ async def _fetch_repo_activity(
if resp.status_code == 200:
data = resp.json()
open_issues = data.get("open_issues_count", 0)
elif resp.status_code == 429:
# Rate limited — back off
await asyncio.sleep(5.0)
except Exception as e:
log_event(f"[WARN] GitHub repo fetch failed for {owner_repo}: {str(e)[:120]}")
await asyncio.sleep(GITHUB_RATE_DELAY)
# Fetch open PRs (page 1 only — we use total_count from search or headers)
try:
resp = await client.get(
f"{GITHUB_API_BASE}/repos/{owner_repo}/pulls",
headers=headers,
params={"state": "open", "sort": "created", "per_page": 1},
timeout=15.0,
)
if resp.status_code == 200:
# The total count of open PRs is not directly in the body for the
# list endpoint, but we can parse the Link header for the last page.
# As a simpler approach, the body is a list; if it has items the repo
# has open PRs. We use the repo-level open_issues_count as the
# combined metric (GitHub counts PRs as issues).
pr_data = resp.json()
if isinstance(pr_data, list) and len(pr_data) > 0:
# Parse Link header for total pages
link_header = resp.headers.get("Link", "")
last_match = re.search(r'page=(\d+)>;\s*rel="last"', link_header)
open_prs = int(last_match.group(1)) if last_match else len(pr_data)
except Exception as e:
log_event(f"[WARN] GitHub PRs fetch failed for {owner_repo}: {str(e)[:120]}")
await asyncio.sleep(GITHUB_RATE_DELAY)
return open_issues, open_prs
return open_issues, 0
def _classify_health(total_activity: int) -> str:
@@ -329,6 +306,7 @@ async def detect_license_changes(inventory: Dict) -> List[Dict]:
log_event("[License] No repos with stored licenses to check")
return []
candidates = candidates[:MAX_REPOS_DEFAULT] # cap to same limit as activity enrichment
log_event(f"[License] Checking {len(candidates)} repos for license changes")
changes: List[Dict] = []
+56 -20
View File
@@ -145,6 +145,12 @@ class NewsDigestEngine:
"12_months": 365,
}
ITEMS_PER_PERIOD: Dict[str, int] = {
"3_months": 10,
"6_months": 15,
"12_months": 20,
}
# ------------------------------------------------------------------ #
def __init__(self) -> None:
@@ -176,26 +182,56 @@ 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
def _is_within_period(entry: dict, cutoff_iso: str) -> bool:
"""Return *True* when the entry's ``discovered_at`` is on or after
the ISO-formatted *cutoff_iso* string. ISO 8601 strings sort
lexicographically so a simple ``>=`` comparison is sufficient.
"""
"""Check if entry falls within the time period using discovered_at,
with year field as fallback for backfilled entries."""
discovered = entry.get("discovered_at", "")
if not discovered:
return False
try:
return discovered >= cutoff_iso
except Exception:
return False
if discovered:
try:
if discovered >= cutoff_iso:
return True
except Exception:
pass
year = entry.get("year", "")
if year and isinstance(year, str) and year.isdigit():
cutoff_year = cutoff_iso[:4] if len(cutoff_iso) >= 4 else "2020"
return year >= cutoff_year
return False
# ------------------------------------------------------------------ #
# Prompt builder #
@@ -253,8 +289,8 @@ class NewsDigestEngine:
"url": e["url"],
"title": e.get("title", "Unknown"),
"date": e.get("discovered_at", "")[:10],
"stars": e.get("stars", 0),
"impact": "high" if e.get("stars", 0) >= 4 else "medium",
"stars": e.get("stars") or 0,
"impact": "high" if (e.get("stars") or 0) >= 4 else "medium",
"why": (e.get("ai_summary", "") or "")[:200],
"category": cat_name,
}
@@ -310,20 +346,20 @@ class NewsDigestEngine:
for cat_name, entries in category_pools.items():
entries.sort(
key=lambda x: (
x.get("stars", 0),
x.get("discovered_at", ""),
x.get("stars") or 0,
x.get("discovered_at") or "",
),
reverse=True,
)
max_items = self.ITEMS_PER_PERIOD.get(period_name, 10)
if len(entries) < 3:
# Too few entries include all without AI ranking
digest[period_name][cat_name] = self._fallback_items(
entries, cat_name
entries, cat_name, limit=max_items
)
continue
# Ask Gemini to rank
try:
prompt = self._build_ranking_prompt(
cat_name, entries, period_name
@@ -351,7 +387,7 @@ class NewsDigestEngine:
}
)
digest[period_name][cat_name] = ranked[:10]
digest[period_name][cat_name] = ranked[:max_items]
log_event(
f" [Digest] {period_name}/{cat_name}: "
f"{len(ranked)} items ranked"
@@ -364,7 +400,7 @@ class NewsDigestEngine:
"using star-based fallback"
)
digest[period_name][cat_name] = self._fallback_items(
entries, cat_name
entries, cat_name, limit=max_items
)
# Respect Gemini rate limits
+111
View File
@@ -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 = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">',
" <channel>",
f" <title>{escape(FEED_TITLE)}</title>",
f" <link>{FEED_LINK}</link>",
f" <description>{escape(FEED_DESCRIPTION)}</description>",
f" <language>{FEED_LANGUAGE}</language>",
f" <lastBuildDate>{build_date}</lastBuildDate>",
f' <atom:link href="{FEED_LINK}feed.xml" rel="self" type="application/rss+xml"/>',
]
for item in items:
title = escape(item.get("title", "Unknown"))
url = item.get("url", "#")
why = escape(item.get("why", ""))
cat = escape(item.get("_cat", ""))
impact = item.get("impact", "medium")
date_str = item.get("date", "")
try:
pub_date = _rfc822(datetime.strptime(date_str, "%Y-%m-%d")) if date_str else build_date
except Exception:
pub_date = build_date
lines += [
" <item>",
f" <title>{title}</title>",
f" <link>{url}</link>",
f" <guid isPermaLink=\"true\">{url}</guid>",
f" <pubDate>{pub_date}</pubDate>",
f" <category>{cat}</category>",
f" <description>[{impact.upper()}] {why}</description>",
" </item>",
]
lines += [" </channel>", "</rss>"]
os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True)
with open(OUTPUT_PATH, "w", encoding="utf-8") as f:
f.write("\n".join(lines) + "\n")
log_event(f"[INFO] rss_generator: wrote {len(items)} items to {OUTPUT_PATH}")
if __name__ == "__main__":
generate_rss()
+28 -12
View File
@@ -629,7 +629,7 @@ class V2VisionEngine:
# 1. GitHub Objective Reality (Mandate 43)
raw_gh = item.get("gh_stars", 0)
gh_stars = int(raw_gh) if str(raw_gh).isdigit() else 0
curator_stars = int(item.get("stars", 0))
curator_stars = int(item.get("stars") or 0)
if gh_stars > 15000 or curator_stars >= 5:
tags.add("[DE FACTO STANDARD]")
@@ -639,7 +639,7 @@ class V2VisionEngine:
if "[COMMUNITY-TOOL]" in tags: tags.remove("[COMMUNITY-TOOL]")
# 2. Type Mapping (AI based labels)
res_type = item.get("resource_type", "Reference").lower()
res_type = (item.get("resource_type") or "Reference").lower()
if any(x in res_type for x in ["guide", "tutorial", "hands-on", "learning", "course"]):
tags.add("[GUIDE]")
if any(x in res_type for x in ["case study", "report", "whitepaper", "success story", "usage"]):
@@ -753,7 +753,7 @@ class V2VisionEngine:
current["__links__"].append(item)
def sort_rec(node):
if "__links__" in node: node["__links__"].sort(key=lambda x: (-x.get("stars", 1), -(int(x["year"]) if str(x.get("year", "")).isdigit() else 0)))
if "__links__" in node: node["__links__"].sort(key=lambda x: (-(x.get("stars") or 1), -(int(x["year"]) if str(x.get("year", "")).isdigit() else 0)))
for k, v in node.items():
if k != "__links__" and isinstance(v, dict): sort_rec(v)
@@ -953,8 +953,9 @@ class V2VisionEngine:
geo_cats = ["Americas", "Europe", "España", "Asia-Pacific"]
period_labels = {"3_months": "Last 3 Months", "6_months": "Last 6 Months", "12_months": "Last 12 Months"}
def render_digest_page(title, categories, digest_data):
md = f"# {title}\n\n"
def render_digest_page(title, categories, digest_data, search_boost=1):
md = f"---\nsearch:\n boost: {search_boost}\n---\n\n"
md += f"# {title}\n\n"
md += "!!! tip \"Nubenetes Intelligence Digest\"\n"
md += " AI-curated ranking of the most impactful resources, updated monthly.\n\n"
for period_key, period_label in period_labels.items():
@@ -964,22 +965,23 @@ class V2VisionEngine:
items = period_data.get(cat, [])
if not items:
continue
md += f" ## {cat}\n\n"
md += f" **{cat}**\n\n"
md += " | Date | Resource | Impact | Why It Matters |\n"
md += " | :--- | :--- | :---: | :--- |\n"
for item in items:
impact_badge = {"critical": "🔴", "high": "🟡", "medium": "🔵"}.get(item.get("impact", "medium"), "🔵")
t = nuclear_strip(item.get("title", "Unknown"))
md += f' | {item.get("date", "")} | [{t}]({item.get("url", "#")}) | {impact_badge} {item.get("impact", "medium")} | {item.get("why", "")} |\n'
why = (item.get("why", "") or "").replace("|", "-").replace("\n", " ")
md += f' | {item.get("date", "")} | [{t}]({item.get("url", "#")}) | {impact_badge} {item.get("impact", "medium")} | {why} |\n'
md += "\n"
md += "\n"
return md
tech_md = render_digest_page("📊 Nubenetes Tech & Cloud Intelligence Digest", tech_cats, digest_data)
tech_md = render_digest_page("📊 Nubenetes Tech & Cloud Intelligence Digest", tech_cats, digest_data, search_boost=2)
with open(os.path.join(V2_DIR, "tech-digest.md"), "w", encoding="utf-8") as f:
f.write(tech_md)
industry_md = render_digest_page("🌍 Nubenetes Industry & Geo Intelligence Digest", geo_cats, digest_data)
industry_md = render_digest_page("🌍 Nubenetes Industry & Geo Intelligence Digest", geo_cats, digest_data, search_boost=2)
with open(os.path.join(V2_DIR, "industry-digest.md"), "w", encoding="utf-8") as f:
f.write(industry_md)
@@ -1005,7 +1007,14 @@ class V2VisionEngine:
top_items = top_items[:6]
impact_icons = {"critical": "🔴", "high": "🟡", "medium": "🔵"}
cards_html = '<div class="trending-section">\n<div class="trending-section__title">🔥 Trending Now — Cloud Native Intelligence</div>\n<div class="trending-grid">\n'
try:
digest_mtime = os.path.getmtime(digest_path)
from datetime import datetime as _dt
digest_updated = _dt.fromtimestamp(digest_mtime).strftime("%b %d, %Y")
except Exception:
digest_updated = ""
updated_badge = f'<span class="trending-section__updated">Updated {digest_updated}</span>' if digest_updated else ""
cards_html = f'<div class="trending-section">\n<div class="trending-section__title">🔥 Trending Now — Cloud Native Intelligence {updated_badge}</div>\n<div class="trending-grid">\n'
for item in top_items:
impact = item.get("impact", "medium")
cards_html += (
@@ -1024,7 +1033,7 @@ class V2VisionEngine:
cards_html += '</div>\n</div>\n'
pulse_md = cards_html
else:
trending_pool = sorted([dict(meta, url=url) for url, meta in self.inventory.items() if isinstance(meta, dict) and meta.get("stars", 0) >= 4], key=lambda x: (str(x.get("year", "0000")) if str(x.get("year", "")).isdigit() else "0000", -x.get("stars", 0)), reverse=True)
trending_pool = sorted([dict(meta, url=url) for url, meta in self.inventory.items() if isinstance(meta, dict) and (meta.get("stars") or 0) >= 4], key=lambda x: (str(x.get("year", "0000")) if str(x.get("year", "")).isdigit() else "0000", -(x.get("stars") or 0)), reverse=True)
pulse_md = "## The Agentic Pulse\n" + "\n".join([f"- **({l.get('year', 'N/A')})** [**=={nuclear_strip(l['title'])}==**]({l['url'].strip()}) {'🌟'*l.get('stars',3)}" for l in trending_pool[:5]])
# Calculate coverage for the index
@@ -1092,6 +1101,13 @@ class V2VisionEngine:
" <div class=\"hero-badge-subtitle\">Agentic Ecosystem</div>\n"
" </div>\n"
" </a>\n"
" <a href=\"./tech-digest/\" style=\"text-decoration: none; color: inherit; display: block;\">\n"
" <div class=\"hero-badge-card hero-badge-card--amber\">\n"
" <div class=\"hero-badge-icon\">📊</div>\n"
" <div class=\"hero-badge-title\">Intelligence Digest</div>\n"
" <div class=\"hero-badge-subtitle\">Top picks · 3/6/12 months</div>\n"
" </div>\n"
" </a>\n"
" <a href=\"./videos/\" style=\"text-decoration: none; color: inherit; display: block;\">\n"
" <div class=\"hero-badge-card hero-badge-card--pink\">\n"
" <img src=\"/images/video_hub_logo.png\" alt=\"Agentic Video Hub\"/>\n"
@@ -1437,7 +1453,7 @@ class V2VisionEngine:
md += f"<summary>{summary_text}</summary>\n\n"
# Sort links under this tag by impact stars and then by year
sorted_links = sorted(by_tag[tag], key=lambda x: (-x.get("stars", 1), -(int(x["year"]) if str(x.get("year", "")).isdigit() else 0)))
sorted_links = sorted(by_tag[tag], key=lambda x: (-(x.get("stars") or 1), -(int(x["year"]) if str(x.get("year", "")).isdigit() else 0)))
rendered_links = sorted_links[:100]
for l in rendered_links:
+7
View File
@@ -39,6 +39,13 @@
<div class="hero-badge-subtitle">Agentic Ecosystem</div>
</div>
</a>
<a href="./tech-digest/" style="text-decoration: none; color: inherit; display: block;">
<div class="hero-badge-card hero-badge-card--amber">
<div class="hero-badge-icon">📊</div>
<div class="hero-badge-title">Intelligence Digest</div>
<div class="hero-badge-subtitle">Top picks · 3/6/12 months</div>
</div>
</a>
<a href="./videos/" style="text-decoration: none; color: inherit; display: block;">
<div class="hero-badge-card hero-badge-card--pink">
<img src="/images/video_hub_logo.png" alt="Agentic Video Hub"/>
+8 -3
View File
@@ -3,7 +3,12 @@
!!! tip "Nubenetes Intelligence Digest"
AI-curated ranking of the most impactful resources, updated monthly.
!!! info "Coming Soon"
The Industry & Geo Digest will be populated automatically when the monthly pipeline runs with Gemini AI ranking. Check back after the next curation cycle.
=== "Last 3 Months"
=== "Last 6 Months"
=== "Last 12 Months"
**4 geographic categories** (Americas, Europe, Spain, Asia-Pacific) will surface industry-relevant resources classified by company and region.
+1328 -3
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -96,10 +96,13 @@ extra:
version:
provider: mike # Ready for version switching
extra_head:
- '<link rel="alternate" type="application/rss+xml" title="Nubenetes Intelligence Digest" href="/feed.xml"/>'
extra_css:
- https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap
- static/extra.css
- static/v2_elite.css?v=2.4.0
- static/v2_elite.css?v=2.7.0
extra_javascript:
- static/v2_filter.js