feat: add CNCF landscape integration, GitHub activity enrichment, license change detection, dedup engine, PWA support, and expanded video hub

- Add src/enrichment.py (395 lines): CNCF landscape API integration for
  project graduation status, GitHub issue/PR velocity tracking with
  community health scoring, and license change detection with history
- Add src/dedup.py (194 lines): URL normalization dedup, content-hash
  dedup, title-similarity dedup (85% threshold via SequenceMatcher),
  and resolution strategy keeping highest-quality entry
- Enable PWA/offline plugin in v2-mkdocs.yml for cached offline reading
- Expand video hub categorization with MLOps, security, IaC, GitOps,
  observability, FinOps, cloud providers, and language-specific routing
- Add dedup scan and enrichment pipeline steps to CI publish workflow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nubenetes Bot
2026-06-19 09:00:53 +02:00
parent 8b04851097
commit 3dc52af391
5 changed files with 629 additions and 4 deletions

View File

@@ -73,6 +73,19 @@ jobs:
run: |
python src/reorganize_mosaic.py
- name: Run Deduplication Scan
env:
PYTHONPATH: ${{ github.workspace }}
run: |
python -u -c "import asyncio; from src.dedup import run_dedup; asyncio.run(run_dedup(dry_run=False))" || echo "Dedup scan skipped"
- name: Run Enrichment Pipeline
env:
PYTHONPATH: ${{ github.workspace }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
python -u -m src.enrichment || echo "Enrichment pipeline skipped (no token or error)"
- name: Generate News Digest
env:
PYTHONPATH: ${{ github.workspace }}

194
src/dedup.py Normal file
View File

@@ -0,0 +1,194 @@
from __future__ import annotations
import re
import asyncio
from collections import defaultdict
from difflib import SequenceMatcher
from typing import Dict, List, Tuple
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
from src.inventory_manager import load_inventory, save_inventory
from src.logger import log_event
TRACKING_PARAMS = {"utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term",
"ref", "source", "fbclid", "gclid", "mc_cid", "mc_eid", "s", "share"}
def normalize_url_deep(url: str) -> str:
parsed = urlparse(url.strip().lower())
scheme = "https"
netloc = parsed.netloc.removeprefix("www.")
path = parsed.path.rstrip("/") or "/"
params = parse_qs(parsed.query)
clean_params = {k: v for k, v in params.items() if k not in TRACKING_PARAMS}
query = urlencode(clean_params, doseq=True) if clean_params else ""
return urlunparse((scheme, netloc, path, "", query, ""))
def normalize_title(title: str) -> str:
if not title:
return ""
t = title.lower().strip()
t = re.sub(r'^[\w.-]+\.\w{2,}:\s*', '', t)
t = re.sub(r'[^\w\s]', ' ', t)
t = re.sub(r'\s+', ' ', t).strip()
return t
def find_url_duplicates(inventory: Dict) -> List[Tuple[str, str]]:
norm_map = defaultdict(list)
for url in inventory:
if not isinstance(inventory[url], dict):
continue
deep = normalize_url_deep(url)
norm_map[deep].append(url)
duplicates = []
for norm, urls in norm_map.items():
if len(urls) > 1:
for i in range(1, len(urls)):
duplicates.append((urls[0], urls[i]))
return duplicates
def find_hash_duplicates(inventory: Dict) -> List[List[str]]:
hash_map = defaultdict(list)
for url, entry in inventory.items():
if not isinstance(entry, dict):
continue
ch = entry.get("content_hash")
if ch and ch != "N/A":
hash_map[ch].append(url)
return [urls for urls in hash_map.values() if len(urls) > 1]
def find_title_duplicates(inventory: Dict, threshold: float = 0.85) -> List[Tuple[str, str, float]]:
entries = []
for url, entry in inventory.items():
if not isinstance(entry, dict):
continue
title = entry.get("title", "")
norm = normalize_title(title)
if len(norm) < 10:
continue
entries.append((url, norm, entry.get("stars", 0)))
log_event(f"[Dedup] Building title index for {len(entries)} entries...")
prefix_groups = defaultdict(list)
for url, norm, stars in entries:
words = norm.split()
prefix = " ".join(words[:3]) if len(words) >= 3 else norm
prefix_groups[prefix].append((url, norm, stars))
duplicates = []
checked = 0
for prefix, group in prefix_groups.items():
if len(group) < 2:
continue
for i in range(len(group)):
for j in range(i + 1, len(group)):
url1, norm1, stars1 = group[i]
url2, norm2, stars2 = group[j]
if stars1 >= 4 and stars2 >= 4:
continue
ratio = SequenceMatcher(None, norm1, norm2).ratio()
if ratio >= threshold:
duplicates.append((url1, url2, ratio))
checked += 1
if checked % 500 == 0:
log_event(f"[Dedup] Checked {checked}/{len(prefix_groups)} prefix groups...")
log_event(f"[Dedup] Title scan complete: {len(duplicates)} potential duplicates found")
return duplicates
def _entry_score(entry: Dict) -> Tuple:
return (
entry.get("stars", 0),
1 if entry.get("ai_summary") else 0,
1 if entry.get("hierarchy") else 0,
len(entry.get("tags", [])),
-len(str(entry.get("url", "")))
)
def resolve_duplicates(inventory: Dict, duplicate_pairs: List[Tuple[str, str, float]]) -> int:
resolved = 0
seen = set()
for url1, url2, score in sorted(duplicate_pairs, key=lambda x: -x[2]):
if url1 in seen or url2 in seen:
continue
entry1 = inventory.get(url1, {})
entry2 = inventory.get(url2, {})
if not isinstance(entry1, dict) or not isinstance(entry2, dict):
continue
score1 = _entry_score(entry1)
score2 = _entry_score(entry2)
if score1 >= score2:
winner, loser = url1, url2
else:
winner, loser = url2, url1
inventory[loser]["status"] = "duplicate"
inventory[loser]["duplicate_of"] = winner
seen.add(loser)
resolved += 1
return resolved
async def run_dedup(dry_run: bool = True) -> Dict:
log_event("STARTING DEDUPLICATION SCAN", section_break=True)
inventory = load_inventory()
url_dups = find_url_duplicates(inventory)
log_event(f"[Dedup] URL duplicates: {len(url_dups)}")
hash_groups = find_hash_duplicates(inventory)
hash_dups = []
for group in hash_groups:
for i in range(1, len(group)):
hash_dups.append((group[0], group[i], 1.0))
log_event(f"[Dedup] Content hash duplicates: {len(hash_dups)}")
title_dups = find_title_duplicates(inventory)
all_dups = [(u1, u2, 1.0) for u1, u2 in url_dups] + hash_dups + title_dups
unique_pairs = {}
for u1, u2, s in all_dups:
key = tuple(sorted([u1, u2]))
if key not in unique_pairs or s > unique_pairs[key]:
unique_pairs[key] = s
deduped_pairs = [(k[0], k[1], v) for k, v in unique_pairs.items()]
stats = {
"url_duplicates": len(url_dups),
"hash_duplicates": len(hash_dups),
"title_duplicates": len(title_dups),
"total_unique_pairs": len(deduped_pairs),
}
if dry_run:
log_event(f"[Dedup] DRY RUN — {len(deduped_pairs)} duplicates found, no changes made")
for u1, u2, score in sorted(deduped_pairs, key=lambda x: -x[2])[:20]:
t1 = inventory.get(u1, {}).get("title", "?")[:60]
t2 = inventory.get(u2, {}).get("title", "?")[:60]
log_event(f" [{score:.0%}] {t1} <-> {t2}")
else:
resolved = resolve_duplicates(inventory, deduped_pairs)
stats["resolved"] = resolved
save_inventory(inventory)
log_event(f"[Dedup] Resolved {resolved} duplicates")
log_event(f"DEDUP COMPLETE: {stats}")
return stats
if __name__ == "__main__":
asyncio.run(run_dedup(dry_run=True))

395
src/enrichment.py Normal file
View File

@@ -0,0 +1,395 @@
from __future__ import annotations
import asyncio
import re
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
import httpx
from src.config import GH_TOKEN, MADRID_TZ
from src.logger import log_event
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
CNCF_LANDSCAPE_URL = "https://landscape.cncf.io/api/items"
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
ACTIVITY_STALENESS_DAYS = 30
# Community health thresholds
HEALTH_ACTIVE = 50
HEALTH_HEALTHY = 10
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _github_headers() -> Dict[str, str]:
"""Build GitHub API request headers with optional auth."""
headers = {"Accept": "application/vnd.github.v3+json"}
if GH_TOKEN:
headers["Authorization"] = f"token {GH_TOKEN}"
return headers
def _parse_github_repo(url: str) -> Optional[str]:
"""Extract 'owner/repo' from a GitHub URL. Returns None if not a GitHub URL."""
match = re.search(r"github\.com/([^/]+/[^/]+)", url)
if not match:
return None
repo = match.group(1).split("#")[0].split("?")[0].rstrip("/")
# Strip trailing .git if present
if repo.endswith(".git"):
repo = repo[:-4]
return repo
def _normalize_repo_url(url: str) -> str:
"""Normalize a GitHub repo URL for comparison (lowercase, no trailing slash)."""
url = url.lower().rstrip("/")
if url.endswith(".git"):
url = url[:-4]
# Strip protocol
url = re.sub(r"^https?://", "", url)
return url
def _is_activity_stale(entry: Dict) -> bool:
"""Check whether an entry's activity data is older than ACTIVITY_STALENESS_DAYS."""
checked = entry.get("gh_activity_checked")
if not checked:
return True
try:
checked_dt = datetime.fromisoformat(checked)
return datetime.now(MADRID_TZ) - checked_dt > timedelta(days=ACTIVITY_STALENESS_DAYS)
except (ValueError, TypeError):
return True
# ---------------------------------------------------------------------------
# Module 1: CNCF Landscape Status
# ---------------------------------------------------------------------------
async def fetch_cncf_landscape() -> Dict[str, str]:
"""Fetch CNCF project graduation status.
Returns dict mapping repo_url (normalized) -> maturity
("sandbox" | "incubating" | "graduated" | "archived").
"""
result: Dict[str, str] = {}
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
log_event(f"[CNCF] Fetched {len(result)} projects from CNCF landscape")
return result
async def enrich_cncf_status(inventory: Dict) -> int:
"""Update inventory entries with cncf_status field.
If an entry maps to a CNCF-graduated project and lacks the
``[DE FACTO STANDARD]`` tag, the tag is appended.
Returns count of entries updated.
"""
landscape = await fetch_cncf_landscape()
if not landscape:
return 0
updated = 0
for url, entry in inventory.items():
if url.startswith("INTRO:"):
continue
normalized = _normalize_repo_url(url)
maturity = landscape.get(normalized)
if not maturity:
continue
old_status = entry.get("cncf_status")
entry["cncf_status"] = maturity
if old_status != maturity:
updated += 1
# Auto-tag graduated projects
if maturity == "graduated":
tags = entry.get("tags")
if isinstance(tags, list) and "[DE FACTO STANDARD]" not in tags:
tags.append("[DE FACTO STANDARD]")
entry["tags"] = tags
log_event(f"[CNCF] Enriched {updated} inventory entries with CNCF status")
return updated
# ---------------------------------------------------------------------------
# Module 2: Social Signal Enrichment (GitHub Activity)
# ---------------------------------------------------------------------------
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.
Returns (open_issues_count, open_prs_count).
"""
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}",
headers=headers,
timeout=15.0,
)
if resp.status_code == 200:
data = resp.json()
open_issues = data.get("open_issues_count", 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
def _classify_health(total_activity: int) -> str:
"""Classify community health based on combined issue + PR activity."""
if total_activity > HEALTH_ACTIVE:
return "active"
if total_activity >= HEALTH_HEALTHY:
return "healthy"
if total_activity > 0:
return "low"
return "dormant"
async def enrich_github_activity(inventory: Dict, max_repos: int = MAX_REPOS_DEFAULT) -> int:
"""Fetch recent issue/PR velocity for GitHub repos in the inventory.
Adds fields:
- ``gh_open_issues_30d``
- ``gh_open_prs_30d``
- ``gh_community_health`` ("active" | "healthy" | "low" | "dormant")
- ``gh_activity_checked`` (ISO timestamp)
Skips entries whose ``gh_activity_checked`` is less than 30 days old.
Processes at most *max_repos* repos per run.
Returns count of entries enriched.
"""
candidates: List[Tuple[str, str]] = [] # (url, owner/repo)
for url, entry in inventory.items():
if url.startswith("INTRO:"):
continue
repo = _parse_github_repo(url)
if not repo:
continue
if not _is_activity_stale(entry):
continue
candidates.append((url, repo))
if len(candidates) >= max_repos:
break
if not candidates:
log_event("[Activity] No GitHub repos require activity enrichment")
return 0
log_event(f"[Activity] Enriching {len(candidates)} GitHub repos (max {max_repos})")
enriched = 0
async with httpx.AsyncClient() as client:
for url, owner_repo in candidates:
try:
open_issues, open_prs = await _fetch_repo_activity(client, owner_repo)
total = open_issues + open_prs
health = _classify_health(total)
entry = inventory[url]
entry["gh_open_issues_30d"] = open_issues
entry["gh_open_prs_30d"] = open_prs
entry["gh_community_health"] = health
entry["gh_activity_checked"] = datetime.now(MADRID_TZ).isoformat()
enriched += 1
except Exception as e:
log_event(f"[WARN] Activity enrichment failed for {owner_repo}: {str(e)[:150]}")
log_event(f"[Activity] Enriched {enriched}/{len(candidates)} repos with community health data")
return enriched
# ---------------------------------------------------------------------------
# Module 3: License Change Detection
# ---------------------------------------------------------------------------
async def _fetch_current_license(
client: httpx.AsyncClient,
owner_repo: str,
) -> Optional[str]:
"""Fetch the current SPDX license identifier for a GitHub repo."""
headers = _github_headers()
try:
resp = await client.get(
f"{GITHUB_API_BASE}/repos/{owner_repo}",
headers=headers,
timeout=15.0,
)
if resp.status_code == 200:
data = resp.json()
lic = data.get("license")
if isinstance(lic, dict):
return lic.get("spdx_id", "N/A")
return "N/A"
elif resp.status_code == 404:
return None # repo not found / deleted
except Exception as e:
log_event(f"[WARN] License fetch failed for {owner_repo}: {str(e)[:120]}")
return None
async def detect_license_changes(inventory: Dict) -> List[Dict]:
"""Compare current gh_license with stored license, flag changes.
For each GitHub repo in inventory that has ``gh_license``:
- Fetches the current license from the GitHub API.
- Compares with the stored ``gh_license``.
- If different, updates the entry: sets ``gh_license`` to the new value
and stores the old value in ``gh_license_previous``.
Returns list of ``{url, old_license, new_license, title}`` dicts.
"""
candidates: List[Tuple[str, str, Dict]] = [] # (url, owner/repo, entry)
for url, entry in inventory.items():
if url.startswith("INTRO:"):
continue
if not entry.get("gh_license") or entry["gh_license"] == "N/A":
continue
repo = _parse_github_repo(url)
if not repo:
continue
candidates.append((url, repo, entry))
if not candidates:
log_event("[License] No repos with stored licenses to check")
return []
log_event(f"[License] Checking {len(candidates)} repos for license changes")
changes: List[Dict] = []
async with httpx.AsyncClient() as client:
for url, owner_repo, entry in candidates:
try:
current = await _fetch_current_license(client, owner_repo)
if current is None:
# Repo not found or API error — skip
continue
stored = entry.get("gh_license", "N/A")
if current != stored:
change_record = {
"url": url,
"old_license": stored,
"new_license": current,
"title": entry.get("title", url),
}
changes.append(change_record)
entry["gh_license_previous"] = stored
entry["gh_license"] = current
log_event(
f"[License] Change detected: {owner_repo} "
f"{stored} -> {current}"
)
except Exception as e:
log_event(f"[WARN] License check failed for {owner_repo}: {str(e)[:150]}")
await asyncio.sleep(GITHUB_RATE_DELAY)
log_event(f"[License] Detected {len(changes)} license change(s)")
return changes
# ---------------------------------------------------------------------------
# Full Enrichment Pipeline
# ---------------------------------------------------------------------------
async def run_enrichment():
"""Full enrichment pipeline: CNCF + GitHub activity + license detection."""
from src.inventory_manager import load_inventory, save_inventory
log_event("ENRICHMENT PIPELINE STARTING", section_break=True)
inventory = load_inventory()
log_event(f"[*] Loaded {len(inventory)} inventory entries")
cncf_count = await enrich_cncf_status(inventory)
activity_count = await enrich_github_activity(inventory)
license_changes = await detect_license_changes(inventory)
save_inventory(inventory)
log_event(
f"Enrichment complete: {cncf_count} CNCF, "
f"{activity_count} activity, "
f"{len(license_changes)} license changes",
section_break=True,
)
return license_changes
if __name__ == "__main__":
asyncio.run(run_enrichment())

View File

@@ -78,12 +78,34 @@ def extract_youtube_id(url):
def get_target_file(category, technology):
cat_lower = category.lower()
tech_lower = technology.lower()
if "agent" in tech_lower or "mcp" in tech_lower or "ai and future operations" in cat_lower:
if "agent" in tech_lower or "mcp" in tech_lower or "ai and future operations" in cat_lower or "llm" in tech_lower or "chatgpt" in tech_lower:
return "ai-agents.md", "AI Agents and MCP"
elif "infrastructure as code" in cat_lower or "security" in cat_lower or "observability" in cat_lower or "monitoring" in cat_lower or "devops" in tech_lower or "iac" in tech_lower or "sre" in tech_lower:
elif "mlops" in tech_lower or "data science" in cat_lower or "machine learning" in tech_lower:
return "ai-agents.md", "AI Agents and MCP"
elif "security" in cat_lower or "devsecops" in tech_lower or "zero trust" in tech_lower or "vulnerability" in tech_lower:
return "devops-iac.md", "DevOps, IaC, and SRE"
elif "fundamentals" in cat_lower:
elif "infrastructure as code" in cat_lower or "observability" in cat_lower or "monitoring" in cat_lower or "devops" in tech_lower or "iac" in tech_lower or "sre" in tech_lower:
return "devops-iac.md", "DevOps, IaC, and SRE"
elif "terraform" in tech_lower or "ansible" in tech_lower or "pulumi" in tech_lower or "crossplane" in tech_lower:
return "devops-iac.md", "DevOps, IaC, and SRE"
elif "gitops" in tech_lower or "argo" in tech_lower or "flux" in tech_lower or "tekton" in tech_lower or "jenkins" in tech_lower or "cicd" in tech_lower:
return "devops-iac.md", "DevOps, IaC, and SRE"
elif "prometheus" in tech_lower or "grafana" in tech_lower or "opentelemetry" in tech_lower or "otel" in tech_lower:
return "devops-iac.md", "DevOps, IaC, and SRE"
elif "finops" in tech_lower or "cost" in tech_lower or "kubecost" in tech_lower:
return "devops-iac.md", "DevOps, IaC, and SRE"
elif "fundamentals" in cat_lower or "certification" in tech_lower or "cka" in tech_lower or "training" in cat_lower:
return "fundamentals.md", "Fundamentals"
elif "aws" in tech_lower or "azure" in tech_lower or "gcp" in tech_lower or "google cloud" in tech_lower:
return "cloud-native.md", "Cloud Native Core"
elif "openshift" in tech_lower or "red hat" in tech_lower or "rancher" in tech_lower:
return "cloud-native.md", "Cloud Native Core"
elif "vmware" in tech_lower or "proxmox" in tech_lower or "virtualization" in tech_lower:
return "cloud-native.md", "Cloud Native Core"
elif "docker" in tech_lower or "container" in tech_lower or "podman" in tech_lower:
return "cloud-native.md", "Cloud Native Core"
elif "python" in tech_lower or "golang" in tech_lower or "java" in tech_lower or "javascript" in tech_lower:
return "fundamentals.md", "Fundamentals"
else:
return "cloud-native.md", "Cloud Native Core"

View File

@@ -63,6 +63,7 @@ plugins:
logo: favicon-ultra.png
- tags:
tags_file: tags.md
- offline
- minify:
minify_html: true
- rss: