# ============================================================================== # Author: Simon-Pierre Boucher # File: creaka/connectors/balados_rss.py # Desc: Connecteur ENRICHISSEMENT « balados-rss » — lecture directe des flux # RSS auto-déclarés des balados (AUCUNE clé requise). Palier 1 (§9). # Cap 150 flux/passage, re-visite 7 j, timeout court, fetch parallèle # poli (les flux vivent presque tous sur des hôtes différents). # ============================================================================== """Enrichissement des balados par leur propre flux RSS. Le flux RSS d'un balado est publié PAR le créateur (auto-déclaré via Apple Podcasts) : c'est la source la plus fraîche et la plus fiable sans clé. On en extrait : - nb d'épisodes réels (````) + date du dernier épisode (``pubDate``) → détection dormant (>12 mois) bien plus juste que le releaseDate iTunes ; - l'image du flux (``itunes:image``/``image/url``) → avatar si absent ; - le lien officiel (````) → compte site-web (ou compte de plateforme si l'URL en est une), signal cross_link — le créateur le déclare lui-même ; - la description du flux → bio si vide ; - les catégories iTunes → niches §6.1. Substitut sans clé de l'API Podcast Index (connecteur `podcastindex`). """ from __future__ import annotations import re from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timedelta, timezone from email.utils import parsedate_to_datetime from xml.etree import ElementTree as ET import requests from ..identity import account from ..normalize import map_niche, platform_from_url from ..schema import Creator, now_iso from .base import USER_AGENT, BaseConnector from .balados_itunes import DORMANT_AFTER_DAYS, _GENRE_NICHE _ITUNES_NS = "{http://www.itunes.com/dtds/podcast-1.0.dtd}" _MAX_BYTES = 8 * 1024 * 1024 # flux géants : on tronque (regex de repli) # hébergeurs de balados : leur page « show » n'est pas le site PERSONNEL du # créateur → jamais transformée en compte site-web _HOSTING_DOMAINS = ( "anchor.fm", "podcasters.spotify.com", "feeds.feedburner.com", "buzzsprout.com", "podbean.com", "spreaker.com", "soundcloud.com", "audioboom.com", "transistor.fm", "simplecast.com", "libsyn.com", "megaphone.fm", "omny.fm", "acast.com", "captivate.fm", "podomatic.com", "rss.com", "pod.link", "podcastics.com", "ausha.co", "pippa.io", "audiomeans.fr", "wordpress.com", "blogspot.com", "squarespace.com", # portails PARTAGÉS entre plusieurs balados : un domaine commun ne doit # JAMAIS devenir un compte site-web (clé forte → risque de fusion §12.2 # entre deux créateurs différents) "baladoquebec.ca", "radio-canada.ca", "qub.ca", "telequebec.tv", "noovo.ca", "urbania.ca", "cogecomedia.com", "985fm.ca", "98.5fm.ca", # domaines des grandes plateformes : si platform_from_url ne reconnaît pas # le lien (page générique), ce n'est PAS un site personnel non plus "facebook.com", "instagram.com", "youtube.com", "tiktok.com", "x.com", "twitter.com", "spotify.com", "apple.com", "google.com", "linktr.ee", "beacons.ai", "patreon.com", ) def _parse_date(raw: str | None) -> datetime | None: """pubDate RFC-2822 (ou ISO en repli) → datetime UTC, sinon None.""" if not raw: return None raw = raw.strip() try: dt = parsedate_to_datetime(raw) except (TypeError, ValueError): try: dt = datetime.fromisoformat(raw.replace("Z", "+00:00")) except ValueError: return None if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt.astimezone(timezone.utc) def parse_feed(raw: bytes) -> dict | None: """Flux RSS (bytes) → dict {episodes, last_episode, image, link, description, categories} ; None si illisible. Voie principale : ElementTree. Repli (flux tronqué/malformé) : regex sur l'en-tête du canal + comptage des `` last): last = dt image = "" itunes_img = channel.find(f"{_ITUNES_NS}image") if itunes_img is not None: image = (itunes_img.get("href") or "").strip() if not image: image = (channel.findtext("image/url") or "").strip() cats: list[str] = [] for cat in channel.iter(f"{_ITUNES_NS}category"): text = (cat.get("text") or "").strip() if text and text not in cats: cats.append(text) description = (channel.findtext("description") or channel.findtext(f"{_ITUNES_NS}summary") or "").strip() # retirer un éventuel balisage HTML de la description description = re.sub(r"<[^>]+>", " ", description) description = re.sub(r"\s+", " ", description).strip() return { "episodes": len(items), "last_episode": last, "image": image, "link": (channel.findtext("link") or "").strip(), "description": description, "categories": cats, } def _parse_feed_regex(raw: bytes) -> dict | None: """Repli tolérant : flux tronqué à _MAX_BYTES ou XML malformé.""" text = raw.decode("utf-8", errors="replace") if "]*href="([^"]+)"', head) m_link = re.search(r"\s*([^<\s]+)\s*", head) m_desc = re.search(r"(?:)?", head, re.S) cats = re.findall(r'itunes:category[^>]*text="([^"]+)"', head) m_date = re.search(r"\s*(.*?)\s*", text) desc = re.sub(r"<[^>]+>", " ", m_desc.group(1)) if m_desc else "" return { "episodes": text.count(" bool: checked = _parse_date(metrics.get("rss_checked")) if checked is None: return True return datetime.now(timezone.utc) - checked > timedelta(days=revisit_days) def _site_domain(link: str) -> str: domain = link.split("://", 1)[-1].split("/", 1)[0].lower() return domain.removeprefix("www.") class BaladosRssConnector(BaseConnector): source_id = "balados-rss" kind = "enrichment" request_delay = 0.2 # hôtes presque tous différents (poli quand même) timeout = 12 # timeout court : un flux lent ne bloque pas le lot max_feeds = 150 # cap par passage (rotation complète en ~3 jours) revisit_days = 7 # cache : un flux déjà lu n'est relu qu'après 7 j workers = 6 def __init__(self) -> None: super().__init__() self.errors = 0 def _fetch_feed(self, url: str) -> dict | None: """Télécharge (borné à _MAX_BYTES) et parse un flux. None si échec.""" resp = requests.get(url, timeout=self.timeout, stream=True, headers={"User-Agent": USER_AGENT}) resp.raise_for_status() chunks, size = [], 0 for chunk in resp.iter_content(chunk_size=65536): chunks.append(chunk) size += len(chunk) if size >= _MAX_BYTES: break resp.close() return parse_feed(b"".join(chunks)) def enrich(self, creators: list[Creator]) -> list[Creator]: # candidats : comptes podcast avec feed_url, pas relus depuis 7 j ; # jamais lus d'abord (metrics.rss_checked absent) todo: list[tuple[Creator, object]] = [] for cr in creators: for acc in cr.platforms: if acc.platform != "podcast" or not acc.metrics.get("feed_url"): continue if _needs_visit(acc.metrics, self.revisit_days): todo.append((cr, acc)) break # un seul balado par fiche todo.sort(key=lambda t: bool(t[1].metrics.get("rss_checked"))) todo = todo[:self.max_feeds] results: dict[int, dict | None] = {} with ThreadPoolExecutor(max_workers=self.workers) as pool: futures = {pool.submit(self._fetch_feed, acc.metrics["feed_url"]): i for i, (_, acc) in enumerate(todo)} for fut in as_completed(futures): try: results[futures[fut]] = fut.result() except Exception: self.errors += 1 results[futures[fut]] = None enriched: list[Creator] = [] now = datetime.now(timezone.utc) for i, (cr, acc) in enumerate(todo): feed = results.get(i) if feed is None: continue # échec réseau/parse : on réessaiera acc.metrics["rss_checked"] = now_iso() acc.last_checked = now_iso() if feed["episodes"]: acc.metrics["episodes"] = feed["episodes"] last = feed["last_episode"] if last is not None: acc.metrics["last_episode"] = last.strftime("%Y-%m-%dT%H:%M:%SZ") if now - last > timedelta(days=DORMANT_AFTER_DAYS): acc.metrics["dormant"] = True # dormant → inactive SEULEMENT si le balado est l'unique # plateforme de la fiche (§13 ; réversible au retour) if all(a.platform == "podcast" for a in cr.platforms): cr.status = "inactive" else: acc.metrics.pop("dormant", None) if feed["image"].startswith("http") and not cr.avatar_url: cr.avatar_url = feed["image"] if feed["description"] and len(cr.bio or "") < 40: cr.bio = feed["description"] niches = {map_niche(_GENRE_NICHE.get(c, c)) for c in feed["categories"]} niches.discard("autre") if niches: cr.niches = sorted(set(cr.niches or []) | niches) # officiel auto-déclaré → compte (plateforme reconnue ou site-web) link = feed["link"] if link.startswith("http"): hit = platform_from_url(link) existing = {a.key for a in cr.platforms} if hit and f"{hit[0]}:{hit[1]}" not in existing: cr.platforms.append( account(hit[0], hit[1], "cross_link", url=link).finalize()) elif not hit: domain = _site_domain(link) if (domain and "." in domain and not any(h in link.lower() for h in _HOSTING_DOMAINS) and f"site-web:{domain}" not in existing): cr.platforms.append( account("site-web", domain, "cross_link", url=link).finalize()) enriched.append(cr) return enriched