# ============================================================================== # Author: Simon-Pierre Boucher # File: creaka/connectors/balados_itunes.py # Desc: Connecteur DÉCOUVERTE — balados québécois via l'API de recherche # iTunes/Apple Podcasts (OFFICIELLE, gratuite, sans clé). Palier 2-3 # (§9). Filtre conservateur : marqueur québécois requis dans le nom du # balado ou de l'artiste. # ============================================================================== """Découverte des podcasteurs québécois via l'API iTunes Search. `itunes.apple.com/search?media=podcast` retourne pour chaque balado : nom, artiste, pochette (artworkUrl600), genres, nb d'épisodes, flux RSS et page Apple Podcasts. Le balado est le compte SOURCE de la fiche → profil_source. """ from __future__ import annotations from datetime import datetime, timedelta, timezone from ..identity import account from ..normalize import slugify from ..schema import Creator from .base import BaseConnector from .youtube_recherche import is_quebec SEARCH_URL = "https://itunes.apple.com/search" # au-delà de 12 mois sans épisode : balado dormant (flag public dans le doc ; # la fiche reste dans l'annuaire — §13, on ne supprime jamais brutalement) DORMANT_AFTER_DAYS = 365 def _dormant(release_date: str | None) -> bool: """Vrai si le dernier épisode (releaseDate iTunes) date de > 12 mois.""" if not release_date: return False try: last = datetime.fromisoformat(release_date.replace("Z", "+00:00")) except ValueError: return False return datetime.now(timezone.utc) - last > timedelta(days=DORMANT_AFTER_DAYS) QUERIES = [ "québécois", "québécoise", "balado québec", "podcast québec", "montréal", "humour québécois", "balado montréal", "saguenay", "gatineau", "sherbrooke", "trois-rivières", "ville de québec balado", "balado gaspésie", "mauricie", "balado estrie", "rimouski", "abitibi", "lévis balado", "laval balado", "balado", "podcast montréal", "balado hockey", "canadiens de montréal", "histoire du québec", "crime québec", "balado finance québec", "balado maman québec", "balado couple québec", "balado santé québec", "balado sport québec", "balado politique québec", "balado culture québec", "balado musique québec", "balado cinéma québec", "balado jeux vidéo québec", "balado plein air québec", "balado immobilier québec", "balado entrepreneur québec", "outaouais", "lanaudière", "laurentides balado", "montérégie", "côte-nord balado", "charlevoix balado", "beauce balado", "drummondville", "victoriaville balado", "granby balado", "chicoutimi", # longue traîne : villes + créneaux (nano/micro podcasteurs locaux) "longueuil", "terrebonne balado", "repentigny balado", "saint-jérôme", "saint-hyacinthe", "shawinigan", "rouyn-noranda", "val-d'or", "sept-îles", "baie-comeau", "alma balado", "matane", "rivière-du-loup", "sorel-tracy", "magog balado", "joliette balado", "châteauguay", "balado chasse québec", "balado pêche québec", "balado agriculture québec", "balado construction québec", "balado véhicule québec", "balado techno québec", "balado ados québec", "balado église québec", "balado histoire vraie québec", "balado humour absurde québec", "balado société québec", "balado femmes québec", "balado papa québec", "balado éducation québec", "balado science québec", "balado littérature québec", "balado poésie québec", "balado bd québec", "balado jeux de société québec", "balado vin québec", "balado bière québec", # vague 4 : villes restantes + créneaux supplémentaires "mascouche", "blainville balado", "brossard", "mirabel", "boucherville", "chambly", "varennes balado", "sainte-julie", "beloeil", "mont-tremblant", "sainte-adèle", "thetford mines", "la tuque", "gaspé", "roberval", "dolbeau", "amqui", "montmagny", "saint-georges beauce", "cowansville", "lachute", "salaberry-de-valleyfield", "sainte-thérèse", "l'assomption", "rawdon", "baie-saint-paul", "la malbaie", "chibougamau", "balado ia québec", "balado crypto québec", "balado marketing québec", "balado droit québec", "balado médecine québec", "balado psycho québec", "balado philosophie québec", "balado environnement québec", "balado autochtone", "balado voyage québec", "balado spiritualité québec", "balado true crime québec", "balado paranormal québec", "balado nostalgie québec", "balado télé québec", "balado wrestling québec", "balado lutte québec", "balado course québec", "balado vélo québec", "balado golf québec", "balado pêche à la mouche québec", "balado gestion québec", "balado rh québec", "balado photo québec", "balado musique émergente québec", "balado métal québec", "balado country québec", "balado horreur québec", "balado cinéma d'horreur", "balado animation québec", "balado improvisation québec", ] _GENRE_NICHE = { "Comedy": "humour", "Food": "bouffe-resto", "Sports": "sport-fitness", "Music": "musique", "Business": "finance-affaires", "Investing": "finance-affaires", "Health & Fitness": "sante-mieux-etre", "Education": "education", "True Crime": "actualite-opinion", "News": "actualite-opinion", "Politics": "actualite-opinion", "Society & Culture": "actualite-opinion", "TV & Film": "actualite-opinion", "Leisure": "gaming", "Video Games": "gaming", "Technology": "tech", "Arts": "arts", "Parenting": "famille-parentalite", "Kids & Family": "famille-parentalite", "Fiction": "arts", "Religion & Spirituality": "sante-mieux-etre", "Science": "education", "History": "education", "Wilderness": "plein-air", } class BaladosItunesConnector(BaseConnector): source_id = "balados-itunes" kind = "discovery" request_delay = 1.5 def fetch(self) -> list[Creator]: creators: list[Creator] = [] seen: set[int] = set() for query in QUERIES: try: resp = self.get(SEARCH_URL, params={ "media": "podcast", "term": query, "country": "CA", "limit": 200}) results = resp.json().get("results") or [] except Exception: continue for r in results: cid = r.get("collectionId") name = (r.get("collectionName") or "").strip() artist = (r.get("artistName") or "").strip() if not cid or cid in seen or not name: continue # filtre conservateur : marqueur QC dans le nom du balado/artiste if not is_quebec(f"{name} {artist}"): continue seen.add(cid) feed = r.get("feedUrl") or "" url = r.get("collectionViewUrl") or feed if not url: continue acc = account("podcast", str(cid), "profil_source", url=url) dormant = _dormant(r.get("releaseDate")) acc.metrics = {k: v for k, v in { "episodes": r.get("trackCount"), "genre": r.get("primaryGenreName"), "feed_url": feed, # site du balado (RSS) "artist_id": r.get("artistId"), "last_episode": r.get("releaseDate"), "dormant": dormant or None, # >12 mois sans épisode }.items() if v} # genres[] complets → niches §6.1 (le genre principal inclus) genres = [g for g in (r.get("genres") or []) if isinstance(g, str)] niches = sorted({_GENRE_NICHE[g] for g in [r.get("primaryGenreName"), *genres] if g in _GENRE_NICHE}) or ["actualite-opinion"] creators.append(Creator( source=self.source_id, external_id=str(cid), display_name=name, bio=f"Balado de {artist}." if artist and artist != name else "", niches=niches, creator_type="podcasteur", avatar_url=r.get("artworkUrl600") or r.get("artworkUrl100"), # §13 : plus de publication depuis 12 mois → inactive # (réversible : redevient actif dès un nouvel épisode) status="inactive" if dormant else "active", platforms=[acc], notes="Référencé sur Apple Podcasts (API de recherche officielle).", source_ids=[f"{self.source_id}:{cid}"], )) return creators