first commit

This commit is contained in:
2026-05-22 09:17:28 +02:00
commit 26b3ed6994
33 changed files with 2943 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml .
RUN pip install --no-cache-dir \
"fastapi>=0.110.0" \
"uvicorn[standard]>=0.29.0" \
"httpx>=0.27.0" \
"pydantic>=2.6.0" \
"apscheduler>=3.10.4"
COPY . .
EXPOSE 8000
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"]
+1
View File
@@ -0,0 +1 @@
# Backend Python packages init
+192
View File
@@ -0,0 +1,192 @@
"""
API FastAPI — Veille YouTube Kubernetes.
"""
import logging
from datetime import datetime, timezone
from typing import Optional
from fastapi import FastAPI, Query, HTTPException, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
from .models import Video, VideoList, RefreshResult, RefreshRequest
from .storage import load_videos, save_videos, get_last_updated, load_config, save_config, load_quota_status, save_quota_status
from .youtube_client import fetch_all_videos, QuotaExceededError
from scoring.scorer import score_video
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
app = FastAPI(
title="YTVeille",
description="API de veille automatique des meilleures vidéos YouTube en français",
version="1.0.0",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
_refresh_running = False
def _run_refresh() -> RefreshResult:
"""Pipeline : fetch → score → persist."""
import asyncio
config = load_config()
queries = config.get("queries")
raw = asyncio.run(fetch_all_videos(queries))
logger.info("Vidéos récupérées : %d", len(raw))
scored = []
for v in raw:
s, topics = score_video(v)
v["score"] = s
v["topics"] = topics
scored.append(v)
# Trier par score décroissant
scored.sort(key=lambda x: x["score"], reverse=True)
save_videos(scored)
logger.info("Vidéos sauvegardées : %d", len(scored))
return RefreshResult(
fetched=len(raw),
scored=len(scored),
stored=len(scored),
timestamp=datetime.now(timezone.utc),
)
@app.get("/api/videos", response_model=VideoList)
def list_videos(
q: Optional[str] = Query(None),
min_score: float = Query(0.0, ge=0, le=100),
topic: Optional[str] = Query(None),
source_query: list[str] = Query(default=[]),
days: int = Query(30, ge=1, le=90),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
):
"""Liste paginée des vidéos avec filtres."""
all_videos = load_videos()
# Filtre texte (titre, chaîne, tags YouTube)
if q:
q_lower = q.lower()
all_videos = [
v for v in all_videos
if q_lower in v.get("title", "").lower()
or q_lower in v.get("channel", "").lower()
or any(q_lower in t.lower() for t in v.get("tags", []))
]
# Filtre par requête source (OR entre les requêtes sélectionnées)
if source_query:
all_videos = [
v for v in all_videos
if any(sq in v.get("source_queries", []) for sq in source_query)
]
# Filtre score
filtered = [v for v in all_videos if v.get("score", 0) >= min_score]
# Filtre topic
if topic:
filtered = [v for v in filtered if topic in v.get("topics", [])]
# Filtre date
from datetime import timedelta
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
result = []
for v in filtered:
pub = v.get("published_at", "")
try:
dt = datetime.fromisoformat(str(pub).replace("Z", "+00:00"))
if dt >= cutoff:
result.append(v)
except Exception:
result.append(v)
total = len(result)
start = (page - 1) * page_size
page_items = result[start : start + page_size]
return VideoList(
total=total,
page=page,
page_size=page_size,
items=[Video(**v) for v in page_items],
)
@app.get("/api/videos/{video_id}", response_model=Video)
def get_video(video_id: str):
"""Détail d'une vidéo par ID."""
all_videos = load_videos()
for v in all_videos:
if v["id"] == video_id:
return Video(**v)
raise HTTPException(status_code=404, detail="Vidéo non trouvée")
@app.get("/api/config")
def get_config():
"""Retourne la configuration de recherche actuelle."""
return load_config()
@app.post("/api/refresh", response_model=RefreshResult)
def refresh(background_tasks: BackgroundTasks, body: Optional[RefreshRequest] = None):
"""Déclenche une mise à jour. Si body.queries fourni, sauvegarde la config."""
global _refresh_running
if _refresh_running:
raise HTTPException(status_code=409, detail="Un refresh est déjà en cours")
if body and body.queries:
save_config({"queries": body.queries})
_refresh_running = True
def _wrapped():
global _refresh_running
try:
_run_refresh()
save_quota_status(False)
except QuotaExceededError:
save_quota_status(True)
logger.warning("Quota YouTube API dépassé — refresh abandonné")
except Exception as exc:
logger.error("Erreur lors du refresh : %s", exc)
finally:
_refresh_running = False
background_tasks.add_task(_wrapped)
return RefreshResult(
fetched=0,
scored=0,
stored=0,
timestamp=datetime.now(timezone.utc),
)
@app.get("/api/status")
def status():
"""État de l'API et date de dernière mise à jour."""
last = get_last_updated()
videos = load_videos()
quota = load_quota_status()
return {
"status": "ok",
"video_count": len(videos),
"last_updated": last.isoformat() if last else None,
"refresh_running": _refresh_running,
"queries": load_config().get("queries", []),
"quota_exceeded": quota.get("exceeded", False),
"quota_exceeded_at": quota.get("exceeded_at"),
}
+46
View File
@@ -0,0 +1,46 @@
from pydantic import BaseModel
from typing import List, Optional
from datetime import datetime
class Video(BaseModel):
id: str
title: str
channel: str
published_at: datetime
duration_seconds: int
view_count: int
like_count: int
thumbnail_url: str
youtube_url: str
tags: List[str] = []
has_chapters: bool = False
score: float = 0.0
topics: List[str] = []
source_queries: List[str] = []
class VideoList(BaseModel):
total: int
page: int
page_size: int
items: List[Video]
class FilterParams(BaseModel):
min_score: float = 0.0
topic: Optional[str] = None
days: int = 30
page: int = 1
page_size: int = 20
class RefreshRequest(BaseModel):
queries: Optional[List[str]] = None
class RefreshResult(BaseModel):
fetched: int
scored: int
stored: int
timestamp: datetime
+92
View File
@@ -0,0 +1,92 @@
"""
Persistance des vidéos dans un fichier JSON.
Écriture atomique pour éviter la corruption.
"""
import json
import os
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
DATA_PATH = Path(os.environ.get("DATA_PATH", "/app/data/videos.json"))
CONFIG_PATH = DATA_PATH.parent / "config.json"
QUOTA_PATH = DATA_PATH.parent / "quota_status.json"
DEFAULT_QUERIES = [
"Kubernetes production français",
"Kubernetes architecture français",
"Kubernetes retour d'expérience",
"Kubernetes incident production français",
"Kubernetes scaling français",
"Kubernetes observabilité",
"Kubernetes tutoriel français",
"Kubernetes déploiement français",
]
def _ensure_dir() -> None:
DATA_PATH.parent.mkdir(parents=True, exist_ok=True)
def load_quota_status() -> dict:
"""Charge l'état du quota YouTube API."""
if not QUOTA_PATH.exists():
return {"exceeded": False, "exceeded_at": None}
with QUOTA_PATH.open("r", encoding="utf-8") as f:
return json.load(f)
def save_quota_status(exceeded: bool) -> None:
"""Persiste l'état du quota YouTube API."""
_ensure_dir()
tmp = QUOTA_PATH.with_suffix(".tmp")
data = {
"exceeded": exceeded,
"exceeded_at": datetime.now(timezone.utc).isoformat() if exceeded else None,
}
with tmp.open("w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False)
tmp.replace(QUOTA_PATH)
def load_config() -> dict:
"""Charge la configuration de recherche (queries)."""
if not CONFIG_PATH.exists():
return {"queries": DEFAULT_QUERIES}
with CONFIG_PATH.open("r", encoding="utf-8") as f:
return json.load(f)
def save_config(config: dict) -> None:
"""Sauvegarde la configuration de recherche."""
_ensure_dir()
tmp = CONFIG_PATH.with_suffix(".tmp")
with tmp.open("w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=2)
tmp.replace(CONFIG_PATH)
def load_videos() -> list[dict[str, Any]]:
"""Charge la liste des vidéos depuis le fichier JSON."""
if not DATA_PATH.exists():
return []
with DATA_PATH.open("r", encoding="utf-8") as f:
return json.load(f)
def save_videos(videos: list[dict[str, Any]]) -> None:
"""Sauvegarde atomique des vidéos (écriture via fichier temporaire)."""
_ensure_dir()
tmp = DATA_PATH.with_suffix(".tmp")
with tmp.open("w", encoding="utf-8") as f:
json.dump(videos, f, ensure_ascii=False, indent=2, default=str)
tmp.replace(DATA_PATH)
def get_last_updated() -> datetime | None:
"""Retourne la date de dernière modification du fichier de données."""
if not DATA_PATH.exists():
return None
return datetime.fromtimestamp(DATA_PATH.stat().st_mtime)
+243
View File
@@ -0,0 +1,243 @@
"""
Client YouTube Data API v3.
Recherche multi-mots-clés de vidéos Kubernetes en français.
"""
import os
import re
import logging
from datetime import datetime, timedelta, timezone
from typing import Any
import httpx
logger = logging.getLogger(__name__)
class QuotaExceededError(Exception):
"""Levée quand le quota journalier YouTube API est dépassé (HTTP 403)."""
YOUTUBE_API_BASE = "https://www.googleapis.com/youtube/v3"
SEARCH_QUERIES = [
"Kubernetes production français",
"Kubernetes architecture français",
"Kubernetes retour d'expérience",
"Kubernetes incident production français",
"Kubernetes scaling français",
"Kubernetes observabilité",
"Kubernetes tutoriel français",
"Kubernetes déploiement français",
]
def _get_api_key() -> str:
key = os.environ.get("YOUTUBE_API_KEY", "")
if not key:
raise RuntimeError("YOUTUBE_API_KEY manquante dans les variables d'environnement")
return key
def _iso_days_ago(days: int = 90) -> str:
dt = datetime.now(timezone.utc) - timedelta(days=days)
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
def _parse_duration(iso_duration: str) -> int:
"""Convertit ISO 8601 duration (PT1H2M3S) en secondes."""
pattern = r"PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?"
m = re.match(pattern, iso_duration or "")
if not m:
return 0
h = int(m.group(1) or 0)
mn = int(m.group(2) or 0)
s = int(m.group(3) or 0)
return h * 3600 + mn * 60 + s
def _has_chapters(description: str) -> bool:
"""Détecte la présence de chapitrage (timestamps 0:00 dans la description)."""
return bool(re.search(r"^\s*\d+:\d+", description or "", re.MULTILINE))
_FRENCH_WORDS = {
"avec", "pour", "dans", "sur", "cette", "votre", "notre", "mais", "très",
"comme", "nous", "vous", "comment", "voici", "voilà", "pourquoi", "mise",
"une", "des", "les", "est", "par", "qui", "que", "aux", "tout", "son",
"leur", "aussi", "bien", "chez", "vers", "sous", "avoir", "être", "faire",
}
_FR_ACCENT_RE = re.compile(r"[éèêëàâùûîïôçœæ]", re.IGNORECASE)
def _is_likely_french(snippet: dict) -> bool:
"""Retourne True si la vidéo est vraisemblablement en français."""
audio_lang = (snippet.get("defaultAudioLanguage") or "").lower()
default_lang = (snippet.get("defaultLanguage") or "").lower()
title = snippet.get("title", "")
first_desc = (snippet.get("description") or "")[:400]
# Langue explicitement déclarée française
if audio_lang.startswith("fr") or default_lang.startswith("fr"):
return True
# Accents français dans le titre → signal fort
if _FR_ACCENT_RE.search(title):
return True
# Langue explicitement anglaise (ou autre non-fr) → rejeter
if audio_lang and not audio_lang.startswith("fr"):
return False
if default_lang and not default_lang.startswith("fr"):
return False
# Langue inconnue : détecter via accents ou mots français dans la description
if _FR_ACCENT_RE.search(first_desc):
return True
words = set(f"{title} {first_desc}".lower().split())
return bool(words & _FRENCH_WORDS)
_STOPWORDS = {
"en", "de", "du", "le", "la", "les", "un", "une", "des", "et", "ou",
"pour", "sur", "avec", "dans", "par", "au", "aux", "ce", "qui", "que",
"français", "french", "fr", "how", "to", "the", "and", "with",
}
def _extract_keywords(query: str) -> list[str]:
"""Extrait les mots-clés significatifs d'une requête (mots > 2 chars, hors stopwords)."""
return [w for w in query.lower().split() if len(w) > 2 and w not in _STOPWORDS]
def _is_relevant(video: dict, source_queries: list[str]) -> bool:
"""Retourne True si au moins un mot-clé de la requête source est dans le titre, les tags ou la description."""
title = video.get("title", "").lower()
tags = " ".join(video.get("tags", [])).lower()
desc = video.get("_desc_preview", "").lower()
text = f"{title} {tags} {desc}"
for query in source_queries:
for kw in _extract_keywords(query):
if kw in text:
return True
return False
def _add_french_hint(query: str) -> str:
"""Ajoute 'français' à la requête si aucun indicateur de langue n'est présent."""
lower = query.lower()
if any(w in lower for w in ["français", "francais", "french", " fr "]):
return query
return f"{query} français"
async def search_videos(query: str, client: httpx.AsyncClient, api_key: str) -> list[str]:
"""Retourne une liste d'IDs vidéos pour une requête donnée."""
params = {
"part": "id",
"q": _add_french_hint(query),
"type": "video",
"relevanceLanguage": "fr",
"regionCode": "FR",
"publishedAfter": _iso_days_ago(90),
"maxResults": 25,
"key": api_key,
}
resp = await client.get(f"{YOUTUBE_API_BASE}/search", params=params, timeout=15.0)
try:
resp.raise_for_status()
except httpx.HTTPStatusError as e:
if e.response.status_code == 403:
raise QuotaExceededError("Quota YouTube API journalier dépassé (HTTP 403)") from e
raise
data = resp.json()
return [item["id"]["videoId"] for item in data.get("items", [])]
async def get_video_details(video_ids: list[str], client: httpx.AsyncClient, api_key: str) -> list[dict[str, Any]]:
"""Retourne les détails enrichis pour une liste d'IDs vidéos."""
if not video_ids:
return []
# Batches de 50 (limite API)
results = []
for i in range(0, len(video_ids), 50):
batch = video_ids[i : i + 50]
params = {
"part": "snippet,contentDetails,statistics",
"id": ",".join(batch),
"key": api_key,
}
resp = await client.get(f"{YOUTUBE_API_BASE}/videos", params=params, timeout=15.0)
resp.raise_for_status()
results.extend(resp.json().get("items", []))
videos = []
for item in results:
snippet = item.get("snippet", {})
stats = item.get("statistics", {})
details = item.get("contentDetails", {})
duration_s = _parse_duration(details.get("duration", ""))
description = snippet.get("description", "")
if not _is_likely_french(snippet):
continue
title = snippet.get("title", "")
videos.append({
"id": item["id"],
"title": title,
"channel": snippet.get("channelTitle", ""),
"published_at": snippet.get("publishedAt", ""),
"duration_seconds": duration_s,
"view_count": int(stats.get("viewCount", 0)),
"like_count": int(stats.get("likeCount", 0)),
"thumbnail_url": snippet.get("thumbnails", {}).get("high", {}).get("url", ""),
"youtube_url": f"https://www.youtube.com/watch?v={item['id']}",
"tags": snippet.get("tags", [])[:20],
"has_chapters": _has_chapters(description),
"_desc_preview": description[:600], # temporaire, nettoyé après filtrage
})
return videos
async def fetch_all_videos(queries: list[str] | None = None) -> list[dict[str, Any]]:
"""Lance la recherche sur tous les mots-clés, déduplique par ID et tracke les requêtes sources."""
if queries is None:
queries = SEARCH_QUERIES
api_key = _get_api_key()
# Map video_id → liste des requêtes qui l'ont renvoyé
id_to_queries: dict[str, list[str]] = {}
async with httpx.AsyncClient() as client:
for query in queries:
try:
ids = await search_videos(query, client, api_key)
for vid_id in ids:
id_to_queries.setdefault(vid_id, []).append(query)
logger.info("Requête '%s'%d IDs", query, len(ids))
except QuotaExceededError:
logger.warning("Quota YouTube dépassé — arrêt des requêtes restantes")
raise
except Exception as exc:
logger.error("Erreur pour la requête '%s': %s", query, exc)
if not id_to_queries:
return []
all_videos = await get_video_details(list(id_to_queries.keys()), client, api_key)
# Attacher les requêtes sources à chaque vidéo
for video in all_videos:
video["source_queries"] = id_to_queries.get(video["id"], [])
# Filtrer les vidéos non pertinentes (titre/tags/description sans aucun mot-clé de la requête)
before = len(all_videos)
all_videos = [v for v in all_videos if _is_relevant(v, v["source_queries"])]
logger.info("Filtre pertinence : %d%d vidéos", before, len(all_videos))
# Supprimer le champ temporaire avant persistance
for v in all_videos:
v.pop("_desc_preview", None)
return all_videos
+29
View File
@@ -0,0 +1,29 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.backends.legacy:build"
[project]
name = "ytveille-backend"
version = "1.0.0"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.110.0",
"uvicorn[standard]>=0.29.0",
"httpx>=0.27.0",
"pydantic>=2.6.0",
"apscheduler>=3.10.4",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"httpx>=0.27.0",
]
[tool.setuptools.packages.find]
where = ["."]
[tool.pytest.ini_options]
testpaths = ["scoring/tests"]
python_files = ["test_*.py"]
+1
View File
@@ -0,0 +1 @@
# Scoring package
+54
View File
@@ -0,0 +1,54 @@
"""
Mots-clés techniques Kubernetes pour le scoring sémantique.
Organisés par thème / topic.
"""
from typing import Dict, List
TOPIC_KEYWORDS: Dict[str, List[str]] = {
"incident": [
"incident", "post-mortem", "postmortem", "panne", "outage",
"crash", "debug", "root cause", "rca", "blameless",
],
"architecture": [
"architecture", "diagram", "schema", "multi-cluster", "multi cluster",
"federation", "service mesh", "sidecar", "istio", "linkerd",
],
"observabilité": [
"prometheus", "grafana", "alertmanager", "loki", "tracing",
"jaeger", "opentelemetry", "metrics", "slo", "sla", "sli",
"observabilité", "monitoring", "dashboards",
],
"sécurité": [
"rbac", "pod security", "opa", "gatekeeper", "falco",
"network policy", "secret", "vault", "trivy", "kubescape",
],
"ci_cd": [
"argocd", "argo cd", "flux", "fluxcd", "helm", "kustomize",
"gitops", "pipeline", "ci/cd", "tekton", "jenkins",
],
"scaling": [
"hpa", "vpa", "keda", "scalabilité", "scaling", "autoscaling",
"horizontal", "vertical", "cluster autoscaler", "cost",
],
"migration": [
"migration", "upgrade", "mise à jour", "version", "deprecation",
"zero downtime", "rolling update", "canary", "blue green",
],
"storage": [
"pvc", "persistent volume", "csi", "storage class", "statefulset",
"rook", "ceph", "nfs", "longhorn", "backup", "velero",
],
}
# Liste plate pour détection rapide
ALL_KEYWORDS: List[str] = [kw for kws in TOPIC_KEYWORDS.values() for kw in kws]
# Mots-clés avancés (bonus score élevé)
ADVANCED_KEYWORDS: List[str] = [
"post-mortem", "postmortem", "root cause", "blameless",
"service mesh", "istio", "linkerd", "opentelemetry",
"rbac", "opa", "gatekeeper", "falco",
"argocd", "gitops", "keda", "hpa",
"multi-cluster", "federation", "zero downtime",
"slo", "sla", "sli", "cluster autoscaler",
]
+115
View File
@@ -0,0 +1,115 @@
"""
Algorithme de scoring des vidéos YouTube Kubernetes.
Score normalisé sur 100.
Critères :
- Vues pondérées par ancienneté : 25 pts
- Ratio likes / vues : 20 pts
- Mots-clés techniques détectés : 25 pts
- Durée >= 10 min : 10 pts
- Présence de chapitrage : 10 pts
- Nombre de topics détectés : 10 pts
"""
from __future__ import annotations
import math
from datetime import datetime, timezone
from typing import TYPE_CHECKING, List, Tuple
from .keywords import TOPIC_KEYWORDS, ADVANCED_KEYWORDS
if TYPE_CHECKING:
from api.models import Video
def _detect_topics(text: str) -> List[str]:
"""Retourne les topics détectés dans un texte (titre + tags)."""
text_lower = text.lower()
topics = []
for topic, keywords in TOPIC_KEYWORDS.items():
if any(kw in text_lower for kw in keywords):
topics.append(topic)
return topics
def _keyword_score(text: str) -> float:
"""Score basé sur les mots-clés techniques (0-25)."""
text_lower = text.lower()
# Nombre de mots-clés avancés trouvés
advanced_hits = sum(1 for kw in ADVANCED_KEYWORDS if kw in text_lower)
# Nombre de mots-clés totaux
from .keywords import ALL_KEYWORDS
total_hits = sum(1 for kw in ALL_KEYWORDS if kw in text_lower)
# On plafonne à 5 hits avancés et 10 hits totaux
score = min(advanced_hits / 5, 1.0) * 15 + min(total_hits / 10, 1.0) * 10
return round(score, 2)
def _view_score(view_count: int, age_days: float) -> float:
"""Vues pondérées par ancienneté (0-25)."""
if age_days <= 0:
age_days = 1
# Vues par jour, log-normalisé
vpd = view_count / age_days
# Référence : 1000 vues/jour = score max
score = min(math.log1p(vpd) / math.log1p(1000), 1.0) * 25
return round(score, 2)
def _like_ratio_score(like_count: int, view_count: int) -> float:
"""Ratio likes/vues (0-20). Référence : 5% = max."""
if view_count == 0:
return 0.0
ratio = like_count / view_count
score = min(ratio / 0.05, 1.0) * 20
return round(score, 2)
def _duration_score(duration_seconds: int) -> float:
"""10 pts si durée >= 10 min, sinon 0."""
return 10.0 if duration_seconds >= 600 else 0.0
def _chapters_score(has_chapters: bool) -> float:
"""10 pts si la vidéo a des chapitres."""
return 10.0 if has_chapters else 0.0
def _topics_score(topics: list[str]) -> float:
"""10 pts selon le nb de topics distincts (max 3)."""
return round(min(len(topics) / 3, 1.0) * 10, 2)
def score_video(video_data: dict) -> Tuple[float, List[str]]:
"""
Calcule le score d'une vidéo et retourne (score, topics).
video_data doit contenir les clés du modèle Video.
"""
now = datetime.now(timezone.utc)
published_at = video_data["published_at"]
if isinstance(published_at, str):
from datetime import datetime as dt
published_at = dt.fromisoformat(published_at.replace("Z", "+00:00"))
age_days = (now - published_at).total_seconds() / 86400
# Texte analysable : titre + tags
tags_text = " ".join(video_data.get("tags", []))
full_text = f"{video_data['title']} {tags_text}"
topics = _detect_topics(full_text)
raw = (
_view_score(video_data["view_count"], age_days)
+ _like_ratio_score(video_data["like_count"], video_data["view_count"])
+ _keyword_score(full_text)
+ _duration_score(video_data["duration_seconds"])
+ _chapters_score(video_data.get("has_chapters", False))
+ _topics_score(topics)
)
# Normaliser sur 100 (max théorique = 25+20+25+10+10+10 = 100)
final_score = round(min(raw, 100.0), 1)
return final_score, topics
+1
View File
@@ -0,0 +1 @@
# Tests scoring
+89
View File
@@ -0,0 +1,89 @@
"""Tests du scorer Kubernetes."""
import pytest
from datetime import datetime, timezone, timedelta
from scoring.scorer import score_video, _detect_topics, _keyword_score
def _base_video(**kwargs) -> dict:
defaults = {
"id": "test123",
"title": "Kubernetes en production",
"channel": "DevOps France",
"published_at": datetime.now(timezone.utc) - timedelta(days=5),
"duration_seconds": 1800, # 30 min
"view_count": 5000,
"like_count": 250,
"thumbnail_url": "https://example.com/thumb.jpg",
"youtube_url": "https://youtube.com/watch?v=test123",
"tags": ["kubernetes", "production", "devops"],
"has_chapters": True,
}
defaults.update(kwargs)
return defaults
class TestScoreVideo:
def test_score_is_between_0_and_100(self):
v = _base_video()
score, topics = score_video(v)
assert 0 <= score <= 100
def test_long_video_scores_higher_than_short(self):
long_v = _base_video(duration_seconds=1800)
short_v = _base_video(duration_seconds=300)
score_long, _ = score_video(long_v)
score_short, _ = score_video(short_v)
assert score_long > score_short
def test_chapters_increase_score(self):
with_ch = _base_video(has_chapters=True)
without_ch = _base_video(has_chapters=False)
s_with, _ = score_video(with_ch)
s_without, _ = score_video(without_ch)
assert s_with > s_without
def test_advanced_keywords_increase_score(self):
basic = _base_video(title="Kubernetes débutant", tags=[])
advanced = _base_video(
title="Kubernetes post-mortem ArgoCD Prometheus",
tags=["istio", "hpa", "keda"],
)
s_basic, _ = score_video(basic)
s_advanced, _ = score_video(advanced)
assert s_advanced > s_basic
def test_topics_detected(self):
v = _base_video(title="Kubernetes ArgoCD CI/CD pipeline GitOps", tags=["argocd", "fluxcd"])
_, topics = score_video(v)
assert "ci_cd" in topics
def test_incident_topic_detected(self):
v = _base_video(title="Kubernetes post-mortem incident production outage")
_, topics = score_video(v)
assert "incident" in topics
def test_zero_views_handled(self):
v = _base_video(view_count=0, like_count=0)
score, _ = score_video(v)
assert score >= 0
class TestDetectTopics:
def test_returns_list(self):
topics = _detect_topics("Kubernetes monitoring Prometheus")
assert isinstance(topics, list)
def test_empty_text(self):
topics = _detect_topics("")
assert topics == []
class TestKeywordScore:
def test_no_keywords(self):
s = _keyword_score("bonjour le monde")
assert s == 0.0
def test_advanced_keyword(self):
s = _keyword_score("post-mortem kubernetes production")
assert s > 0
+15
View File
@@ -0,0 +1,15 @@
FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml .
RUN pip install --no-cache-dir \
"fastapi>=0.110.0" \
"uvicorn[standard]>=0.29.0" \
"httpx>=0.27.0" \
"pydantic>=2.6.0" \
"apscheduler>=3.10.4"
COPY . .
CMD ["python", "worker/scheduler.py"]
+1
View File
@@ -0,0 +1 @@
# Worker package
+73
View File
@@ -0,0 +1,73 @@
"""
Worker cron — Veille YouTube Kubernetes.
Mise à jour quotidienne à 6h UTC via APScheduler.
"""
import asyncio
import logging
import os
from datetime import datetime, timezone
from apscheduler.schedulers.blocking import BlockingScheduler
from apscheduler.triggers.cron import CronTrigger
# Ajout du répertoire parent au path Python
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from api.storage import save_videos, load_config
from api.youtube_client import fetch_all_videos
from scoring.scorer import score_video
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
def run_pipeline() -> None:
"""Pipeline complet : fetch → score → persist."""
logger.info("=== Démarrage du pipeline de mise à jour ===")
start = datetime.now(timezone.utc)
try:
config = load_config()
queries = config.get("queries")
raw = asyncio.run(fetch_all_videos(queries))
logger.info("Vidéos récupérées : %d", len(raw))
scored = []
for v in raw:
s, topics = score_video(v)
v["score"] = s
v["topics"] = topics
scored.append(v)
scored.sort(key=lambda x: x["score"], reverse=True)
save_videos(scored)
elapsed = (datetime.now(timezone.utc) - start).total_seconds()
logger.info(
"=== Pipeline terminé : %d vidéos en %.1f secondes ===",
len(scored),
elapsed,
)
except Exception as exc:
logger.error("Erreur pipeline : %s", exc, exc_info=True)
if __name__ == "__main__":
# Exécution immédiate au démarrage puis cron quotidien
logger.info("Worker démarré — exécution immédiate puis cron à 6h UTC")
run_pipeline()
scheduler = BlockingScheduler(timezone="UTC")
scheduler.add_job(
run_pipeline,
trigger=CronTrigger(hour=6, minute=0),
id="daily_refresh",
name="Mise à jour quotidienne des vidéos",
)
logger.info("Scheduler configuré : cron 6h00 UTC")
scheduler.start()