palmares-apple V4 : balayage des tops 100 par genre (le top général CA est devenu 100 % anglophone → rendement 0 et alertes) ; préfiltre nom/artiste/résumé, attribution QC inchangée, métriques chart_genre/chart_rank_genre
1 changed file +79 −11
modified
creaka/connectors/palmares_apple.py
+79 −11
@@ -1,10 +1,11 @@ | |||||
| 1 | # ============================================================================== | 1 | # ============================================================================== | ||
| 2 | # Author: Simon-Pierre Boucher <contact@spboucher.ai> | 2 | # Author: Simon-Pierre Boucher <contact@spboucher.ai> | ||
| 3 | # File: creaka/connectors/palmares_apple.py | 3 | # File: creaka/connectors/palmares_apple.py | ||
| 4 | −# Desc: Connecteur DÉCOUVERTE — balados québécois dans le TOP 100 canadien | 4 | +# Desc: Connecteur DÉCOUVERTE — balados québécois dans les palmarès canadiens | ||
| 5 | −# d'Apple Podcasts. Mode d'accès : API marketing officielle d'Apple | 5 | +# d'Apple Podcasts : top 100 général (API marketing officielle, | ||
| 6 | −# (rss.marketingtools.apple.com, gratuite, sans clé) + lookup iTunes | 6 | +# rss.marketingtools.apple.com) + tops 100 PAR GENRE (API RSS iTunes | ||
| 7 | −# groupé + tête de flux RSS pour l'attribution QC. Palier 2 (§9). V3. | 7 | +# officielle, itunes.apple.com/ca/rss) + lookup iTunes groupé + tête de | ||
| 8 | +# flux RSS pour l'attribution QC. Palier 2 (§9). V4. | ||||
| 8 | # ============================================================================== | 9 | # ============================================================================== | ||
| 9 | """Palmarès Apple Podcasts Canada → balados québécois qui percent. | 10 | """Palmarès Apple Podcasts Canada → balados québécois qui percent. | ||
| 10 | 11 | ||||
@@ -27,6 +28,13 @@ surtout anglo-canadien, le rendement est faible mais de très haute valeur : | |||||
| 27 | un balado QC du top 100 canadien est un créateur qui compte. Complémentaire | 28 | un balado QC du top 100 canadien est un créateur qui compte. Complémentaire | ||
| 28 | de balados-itunes (recherche thématique) — même clé de compte | 29 | de balados-itunes (recherche thématique) — même clé de compte | ||
| 29 | (podcast:collectionId) → fusion naturelle des fiches. | 30 | (podcast:collectionId) → fusion naturelle des fiches. | ||
| 31 | + | ||||
| 32 | +V4 (2026-08-20) : le top 100 général CA est devenu 100 % anglophone (0 balado | ||||
| 33 | +QC pendant plusieurs passages → alertes). On balaie EN PLUS les tops 100 par | ||||
| 34 | +genre de l'API RSS iTunes (officielle, sans clé) : le préfiltre nom/artiste/ | ||||
| 35 | +résumé limite les lectures RSS, et l'attribution finale reste identique | ||||
| 36 | +(nom/artiste QC, sinon tête de flux fr + marqueur QC — jamais le résumé seul, | ||||
| 37 | +qui sert uniquement de préfiltre). | ||||
| 30 | """ | 38 | """ | ||
| 31 | from __future__ import annotations | 39 | from __future__ import annotations | ||
| 32 | 40 | ||||
@@ -42,8 +50,20 @@ from .youtube_recherche import is_quebec | |||||
| 42 | 50 | ||||
| 43 | CHARTS_URL = ("https://rss.marketingtools.apple.com/api/v2/ca/podcasts/" | 51 | CHARTS_URL = ("https://rss.marketingtools.apple.com/api/v2/ca/podcasts/" | ||
| 44 | "top/100/podcasts.json") | 52 | "top/100/podcasts.json") | ||
| 53 | +GENRE_CHART_URL = ("https://itunes.apple.com/ca/rss/toppodcasts/" | ||||
| 54 | + "limit=100/genre={gid}/json") | ||||
| 45 | LOOKUP_URL = "https://itunes.apple.com/lookup" | 55 | LOOKUP_URL = "https://itunes.apple.com/lookup" | ||
| 46 | 56 | ||||
| 57 | +# 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 | +} | ||||
| 66 | + | ||||
| 47 | _HEAD_BYTES = 40_000 # tête du flux RSS lue pour l'attribution QC | 67 | _HEAD_BYTES = 40_000 # tête du flux RSS lue pour l'attribution QC | ||
| 48 | 68 | ||||
| 49 | 69 | ||||
@@ -81,6 +101,25 @@ class PalmaresAppleConnector(BaseConnector): | |||||
| 81 | data = self.get(CHARTS_URL).json() | 101 | data = self.get(CHARTS_URL).json() | ||
| 82 | return (data.get("feed") or {}).get("results") or [] | 102 | return (data.get("feed") or {}).get("results") or [] | ||
| 83 | 103 | ||||
| 104 | + 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 out | ||||
| 122 | + | ||||
| 84 | def _lookup(self, ids: list[str]) -> dict[str, dict]: | 123 | def _lookup(self, ids: list[str]) -> dict[str, dict]: | ||
| 85 | """Lookup iTunes GROUPÉ (officiel) : une seule requête pour le top.""" | 124 | """Lookup iTunes GROUPÉ (officiel) : une seule requête pour le top.""" | ||
| 86 | resp = self.get(LOOKUP_URL, params={"id": ",".join(ids), | 125 | resp = self.get(LOOKUP_URL, params={"id": ",".join(ids), | ||
@@ -108,18 +147,42 @@ class PalmaresAppleConnector(BaseConnector): | |||||
| 108 | print(f"[crea-ka] ⚠ palmares-apple : palmarès indisponible : {exc}", | 147 | print(f"[crea-ka] ⚠ palmares-apple : palmarès indisponible : {exc}", | ||
| 109 | file=sys.stderr) | 148 | file=sys.stderr) | ||
| 110 | self.errors += 1 | 149 | self.errors += 1 | ||
| 150 | + 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 lectures | ||||
| 157 | + # RSS) — l'attribution finale reste nom/artiste ou tête de flux | ||||
| 158 | + for gid, label in _GENRE_CHARTS.items(): | ||||
| 159 | + try: | ||||
| 160 | + genre_entries = self._genre_chart(gid) | ||||
| 161 | + except Exception: | ||||
| 162 | + self.errors += 1 | ||||
| 163 | + continue | ||||
| 164 | + for entry in genre_entries: | ||||
| 165 | + cid = str(entry.get("id") or "") | ||||
| 166 | + if not cid or cid in candidates: | ||||
| 167 | + continue | ||||
| 168 | + if not is_quebec(f"{entry.get('name', '')} " | ||||
| 169 | + f"{entry.get('artistName', '')} " | ||||
| 170 | + f"{entry.get('summary', '')}"): | ||||
| 171 | + continue | ||||
| 172 | + candidates[cid] = {**entry, "chart": label} | ||||
| 173 | + if not candidates: | ||||
| 111 | return [] | 174 | return [] | ||
| 112 | details: dict[str, dict] = {} | 175 | details: dict[str, dict] = {} | ||
| 113 | try: | 176 | try: | ||
| 114 | − details = self._lookup([e["id"] for e in chart if e.get("id")]) | 177 | + details = self._lookup(list(candidates)) | ||
| 115 | except Exception: | 178 | except Exception: | ||
| 116 | − self.errors += 1 # dégradé : on continue avec le palmarès seul | 179 | + self.errors += 1 # dégradé : on continue avec les palmarès seuls | ||
| 117 | creators: list[Creator] = [] | 180 | creators: list[Creator] = [] | ||
| 118 | − for rank, entry in enumerate(chart, start=1): | 181 | + for cid, entry in candidates.items(): | ||
| 119 | − cid = str(entry.get("id") or "") | 182 | + rank = entry["rank"] | ||
| 120 | name = (entry.get("name") or "").strip() | 183 | name = (entry.get("name") or "").strip() | ||
| 121 | artist = (entry.get("artistName") or "").strip() | 184 | artist = (entry.get("artistName") or "").strip() | ||
| 122 | − if not cid or not name: | 185 | + if not name: | ||
| 123 | continue | 186 | continue | ||
| 124 | info = details.get(cid) or {} | 187 | info = details.get(cid) or {} | ||
| 125 | feed = info.get("feedUrl") or "" | 188 | feed = info.get("feedUrl") or "" | ||
@@ -137,8 +200,11 @@ class PalmaresAppleConnector(BaseConnector): | |||||
| 137 | continue | 200 | continue | ||
| 138 | acc = account("podcast", cid, "profil_source", url=url) | 201 | acc = account("podcast", cid, "profil_source", url=url) | ||
| 139 | dormant = _dormant(info.get("releaseDate")) | 202 | dormant = _dormant(info.get("releaseDate")) | ||
| 203 | + chart = entry.get("chart") # None = top général, sinon genre | ||||
| 140 | acc.metrics = {k: v for k, v in { | 204 | acc.metrics = {k: v for k, v in { | ||
| 141 | − "chart_rank_ca": rank, # rang du top 100 CA | 205 | + "chart_rank_ca": None if chart else rank, | ||
| 206 | + "chart_genre": chart, | ||||
| 207 | + "chart_rank_genre": rank if chart else None, | ||||
| 142 | "episodes": info.get("trackCount"), | 208 | "episodes": info.get("trackCount"), | ||
| 143 | "genre": info.get("primaryGenreName"), | 209 | "genre": info.get("primaryGenreName"), | ||
| 144 | "feed_url": feed, | 210 | "feed_url": feed, | ||
@@ -162,7 +228,9 @@ class PalmaresAppleConnector(BaseConnector): | |||||
| 162 | or entry.get("artworkUrl100"), | 228 | or entry.get("artworkUrl100"), | ||
| 163 | status="inactive" if dormant else "active", | 229 | status="inactive" if dormant else "active", | ||
| 164 | platforms=[acc], | 230 | platforms=[acc], | ||
| 165 | − notes=f"Top 100 balados Apple Podcasts Canada (rang {rank}).", | 231 | + notes=(f"Top 100 balados Apple Podcasts Canada — " | ||
| 232 | + f"{chart} (rang {rank})." if chart else | ||||
| 233 | + f"Top 100 balados Apple Podcasts Canada (rang {rank})."), | ||||
| 166 | source_ids=[f"{self.source_id}:{cid}"], | 234 | source_ids=[f"{self.source_id}:{cid}"], | ||
| 167 | )) | 235 | )) | ||
| 168 | return creators | 236 | return creators | ||
| 169 | 237 | ||||