first commit
This commit is contained in:
@@ -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"]
|
||||
@@ -0,0 +1 @@
|
||||
# Backend Python packages init
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -0,0 +1 @@
|
||||
# Scoring package
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
# Tests scoring
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -0,0 +1 @@
|
||||
# Worker package
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user