SPB Git forge

spb/crea-ka

Public

Créa·Ka — annuaire public cross-plateforme des créateurs de contenu québécois (crea-ka.com)

52commits 1branches 0releases
11.3 MBsize
maindefault branch
19 days agolast push
Python 73.6% HTML 13.2% TypeScript 6% JavaScript 4.5% CSS 1.7% Dockerfile 0.6%
10.8 KB · 237 lines python
Raw Blame History
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