Créa·Ka — annuaire public cross-plateforme des créateurs de contenu québécois (crea-ka.com)
Python 73.6%
HTML 13.2%
TypeScript 6%
JavaScript 4.5%
CSS 1.7%
Dockerfile 0.6%
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File: creaka/connectors/palmares_apple.py4# Desc: Connecteur DÉCOUVERTE — balados québécois dans les palmarès canadiens5# d'Apple Podcasts : top 100 général (API marketing officielle,6# rss.marketingtools.apple.com) + tops 100 PAR GENRE (API RSS iTunes7# officielle, itunes.apple.com/ca/rss) + lookup iTunes groupé + tête de8# flux RSS pour l'attribution QC. Palier 2 (§9). V4.9# ==============================================================================10"""Palmarès Apple Podcasts Canada → balados québécois qui percent.1112Évaluation vague 3 des palmarès publics (2026-08-18) :13- Spotify : podcastcharts.byspotify.com/api → HTTP 500 sur toutes les14 variantes (API morte), charts.spotify.com → mur de connexion. Écarté.15- ADISQ (palmaresadisq.ca) : SPA JavaScript (pjax renvoie la coquille vide),16 et un palmarès d'ARTISTES sans liens sociaux → fiches sans lien, rejetées17 par le schéma. Écarté.18- Bible urbaine : articles éditoriaux non structurés (noms en prose, sans19 handles) → liaison ambiguë interdite (§12). Écarté.20- Apple Podcasts : API marketing OFFICIELLE, JSON stable, sans clé. Retenu.2122Chaîne : top 100 CA (rang = signal de traction) → lookup iTunes GROUPÉ (une23requête pour tous les ids : flux RSS, nb d'épisodes, dernier épisode, genres)24→ attribution QC conservatrice : marqueur québécois dans le nom/artiste, ou25dans la TÊTE du flux RSS auto-déclaré (avant le premier <item> : titre,26description, auteur — jamais le contenu des épisodes). Le palmarès étant27surtout anglo-canadien, le rendement est faible mais de très haute valeur :28un balado QC du top 100 canadien est un créateur qui compte. Complémentaire29de balados-itunes (recherche thématique) — même clé de compte30(podcast:collectionId) → fusion naturelle des fiches.3132V4 (2026-08-20) : le top 100 général CA est devenu 100 % anglophone (0 balado33QC pendant plusieurs passages → alertes). On balaie EN PLUS les tops 100 par34genre de l'API RSS iTunes (officielle, sans clé) : le préfiltre nom/artiste/35résumé limite les lectures RSS, et l'attribution finale reste identique36(nom/artiste QC, sinon tête de flux fr + marqueur QC — jamais le résumé seul,37qui sert uniquement de préfiltre).38"""39from __future__ import annotations4041import re42import sys43import time4445from ..identity import account46from ..schema import Creator47from .base import BaseConnector48from .balados_itunes import _GENRE_NICHE, _dormant49from .youtube_recherche import is_quebec5051CHARTS_URL = ("https://rss.marketingtools.apple.com/api/v2/ca/podcasts/"52 "top/100/podcasts.json")53GENRE_CHART_URL = ("https://itunes.apple.com/ca/rss/toppodcasts/"54 "limit=100/genre={gid}/json")55LOOKUP_URL = "https://itunes.apple.com/lookup"5657# genres Apple Podcasts balayés en plus du top général (id officiel → libellé)58_GENRE_CHARTS = {59 1301: "Arts", 1321: "Affaires", 1303: "Humour", 1304: "Éducation",60 1483: "Fiction", 1511: "Gouvernement", 1512: "Santé et mieux-être",61 1487: "Histoire", 1305: "Enfants et famille", 1502: "Loisirs",62 1310: "Musique", 1489: "Nouvelles", 1314: "Religion et spiritualité",63 1533: "Sciences", 1324: "Société et culture", 1545: "Sports",64 1318: "Technologies", 1488: "Crimes véritables", 1309: "Télé et cinéma",65}6667_HEAD_BYTES = 40_000 # tête du flux RSS lue pour l'attribution QC686970def feed_head_is_quebec(head: str) -> bool:71 """Attribution QC PRUDENTE par l'en-tête du canal RSS (avant <item>).7273 Exige les DEUX : canal francophone (<language> fr…) ET marqueur québécois74 dans les balises descriptives du canal. Un balado anglo qui mentionne75 « Montreal » (dates de tournée…) ne passe JAMAIS (constaté 2026-08-18 :76 Giggly Squad/MeidasTouch dans le top 100 CA) ; un balado QC anglophone77 sans marqueur au nom est manqué — mieux vaut une fiche de moins qu'une78 attribution erronée (§7/§15).79 """80 channel = (head or "").split("<item")[0]81 lang = re.search(r"<language>\s*([A-Za-z-]+)", channel, re.I)82 if not lang or not lang.group(1).lower().startswith("fr"):83 return False84 # balises descriptives du canal uniquement85 fields = re.findall(86 r"<(?:title|description|itunes:summary|itunes:author|copyright|"87 r"managingEditor)[^>]*>(.*?)</", channel, re.S | re.I)88 return is_quebec(" ".join(fields))899091class PalmaresAppleConnector(BaseConnector):92 source_id = "palmares-apple"93 kind = "discovery"94 request_delay = 1.09596 def __init__(self) -> None:97 super().__init__()98 self.errors = 099100 def _chart(self) -> list[dict]:101 data = self.get(CHARTS_URL).json()102 return (data.get("feed") or {}).get("results") or []103104 def _genre_chart(self, gid: int) -> list[dict]:105 """Top 100 CA d'un genre (API RSS iTunes) → entrées normalisées."""106 data = self.get(GENRE_CHART_URL.format(gid=gid)).json()107 out: list[dict] = []108 for rank, e in enumerate((data.get("feed") or {}).get("entry") or [],109 start=1):110 cid = (((e.get("id") or {}).get("attributes") or {})111 .get("im:id") or "")112 out.append({113 "id": cid,114 "name": ((e.get("im:name") or {}).get("label") or "").strip(),115 "artistName": ((e.get("im:artist") or {}).get("label")116 or "").strip(),117 "summary": ((e.get("summary") or {}).get("label") or ""),118 "url": ((e.get("id") or {}).get("label") or ""),119 "rank": rank,120 })121 return out122123 def _lookup(self, ids: list[str]) -> dict[str, dict]:124 """Lookup iTunes GROUPÉ (officiel) : une seule requête pour le top."""125 resp = self.get(LOOKUP_URL, params={"id": ",".join(ids),126 "country": "CA"})127 return {str(r.get("collectionId")): r128 for r in resp.json().get("results") or []129 if r.get("collectionId")}130131 def _feed_head(self, url: str) -> str:132 """Début du flux RSS (40 Ko max) — assez pour l'en-tête du canal."""133 self._throttle()134 resp = self.session.get(url, timeout=15, stream=True)135 try:136 resp.raise_for_status()137 chunk = next(resp.iter_content(_HEAD_BYTES), b"") or b""138 finally:139 resp.close()140 self._last_request = time.time()141 return chunk.decode("utf-8", "ignore")142143 def fetch(self) -> list[Creator]:144 try:145 chart = self._chart()146 except Exception as exc:147 print(f"[crea-ka] ⚠ palmares-apple : palmarès indisponible : {exc}",148 file=sys.stderr)149 self.errors += 1150 chart = []151 candidates: dict[str, dict] = {}152 for rank, entry in enumerate(chart, start=1):153 cid = str(entry.get("id") or "")154 if cid:155 candidates[cid] = {**entry, "rank": rank, "chart": None}156 # tops par genre : préfiltre nom/artiste/résumé (limite les lectures157 # RSS) — l'attribution finale reste nom/artiste ou tête de flux158 for gid, label in _GENRE_CHARTS.items():159 try:160 genre_entries = self._genre_chart(gid)161 except Exception:162 self.errors += 1163 continue164 for entry in genre_entries:165 cid = str(entry.get("id") or "")166 if not cid or cid in candidates:167 continue168 if not is_quebec(f"{entry.get('name', '')} "169 f"{entry.get('artistName', '')} "170 f"{entry.get('summary', '')}"):171 continue172 candidates[cid] = {**entry, "chart": label}173 if not candidates:174 return []175 details: dict[str, dict] = {}176 try:177 details = self._lookup(list(candidates))178 except Exception:179 self.errors += 1 # dégradé : on continue avec les palmarès seuls180 creators: list[Creator] = []181 for cid, entry in candidates.items():182 rank = entry["rank"]183 name = (entry.get("name") or "").strip()184 artist = (entry.get("artistName") or "").strip()185 if not name:186 continue187 info = details.get(cid) or {}188 feed = info.get("feedUrl") or ""189 # attribution QC conservatrice : nom/artiste, sinon tête du flux190 quebec = is_quebec(f"{name} {artist}")191 if not quebec and feed:192 try:193 quebec = feed_head_is_quebec(self._feed_head(feed))194 except Exception:195 pass # flux injoignable → pas d'attribution, on passe196 if not quebec:197 continue198 url = entry.get("url") or info.get("collectionViewUrl") or feed199 if not url:200 continue201 acc = account("podcast", cid, "profil_source", url=url)202 dormant = _dormant(info.get("releaseDate"))203 chart = entry.get("chart") # None = top général, sinon genre204 acc.metrics = {k: v for k, v in {205 "chart_rank_ca": None if chart else rank,206 "chart_genre": chart,207 "chart_rank_genre": rank if chart else None,208 "episodes": info.get("trackCount"),209 "genre": info.get("primaryGenreName"),210 "feed_url": feed,211 "artist_id": info.get("artistId"),212 "last_episode": info.get("releaseDate"),213 "dormant": dormant or None,214 }.items() if v}215 genres = [g.get("name") for g in (entry.get("genres") or [])216 if isinstance(g, dict)]217 niches = sorted({_GENRE_NICHE[g] for g in218 [info.get("primaryGenreName"), *genres]219 if g in _GENRE_NICHE}) or ["actualite-opinion"]220 creators.append(Creator(221 source=self.source_id,222 external_id=cid,223 display_name=name,224 bio=f"Balado de {artist}." if artist and artist != name else "",225 niches=niches,226 creator_type="podcasteur",227 avatar_url=info.get("artworkUrl600")228 or entry.get("artworkUrl100"),229 status="inactive" if dormant else "active",230 platforms=[acc],231 notes=(f"Top 100 balados Apple Podcasts Canada — "232 f"{chart} (rang {rank})." if chart else233 f"Top 100 balados Apple Podcasts Canada (rang {rank})."),234 source_ids=[f"{self.source_id}:{cid}"],235 ))236 return creators237