Vague 3 : Twitch GQL public sans clé (abonnés), stats YouTube sans clé (InnerTube), découvertes wikipedia-qc + palmares-apple, fix marqueurs QC
- twitch : plus de SkipSource — API GQL publique du site (Client-ID public, requêtes groupées 20/POST) : abonnés, bio, avatar, partenaire/affilié, dernière diffusion ; voie Helix officielle conservée si clés fournies ; repli curl-binaire si empreinte TLS refusée. 21/26 comptes avec abonnés. - youtube : voie publique complète sans clé — page de chaîne + InnerTube browse du panneau À propos (vues totales, nb vidéos, pays) + page /videos (date de la dernière vidéo → dormant >12 mois) ; bannière, avatar, description → bio ; cap 300 chaînes/passage, rotation 14 j (yt_checked). Passage initial : 264 chaînes (vues+vidéos 264, last_video 261, bannières 245). - wikipedia-qc (découverte) : catégories fr+en de créateurs QC (API MediaWiki) → marqueur QC obligatoire pour les catégories pan-canadiennes → handles sociaux curés Wikidata (P2003/P7085/P2397/P2002/P5797/P2013 + P856) ; décédés exclus, is_minor via P569. 76 nouvelles fiches + 35 fusions. - palmares-apple (découverte) : top 100 podcasts Canada (API marketing officielle Apple, sans clé) + lookup groupé + attribution QC prudente (langue fr ET marqueur dans l en-tête du flux). Spotify charts (API morte), ADISQ (SPA sans liens) et Bible urbaine (prose sans handles) évalués et écartés — documenté dans sources.json. - fix _QC_MARKERS : frontières de mot (« levis » matchait teLEVISion, « estrie » tapESTRIEs) — faux positifs purgés de la base. - sources.json (16 sources), docs connecteurs régénérées, tests vague 3 (46 verts). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
25 changed files +1,312 −196
added
creaka/connectors/palmares_apple.py
+168 −0
@@ -0,0 +1,168 @@ | ||
| 1 | +# ============================================================================== | |
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +# File: creaka/connectors/palmares_apple.py | |
| 4 | +# Desc: Connecteur DÉCOUVERTE — balados québécois dans le TOP 100 canadien | |
| 5 | +# d'Apple Podcasts. Mode d'accès : API marketing officielle d'Apple | |
| 6 | +# (rss.marketingtools.apple.com, gratuite, sans clé) + lookup iTunes | |
| 7 | +# groupé + tête de flux RSS pour l'attribution QC. Palier 2 (§9). V3. | |
| 8 | +# ============================================================================== | |
| 9 | +"""Palmarès Apple Podcasts Canada → balados québécois qui percent. | |
| 10 | + | |
| 11 | +Évaluation vague 3 des palmarès publics (2026-08-18) : | |
| 12 | +- Spotify : podcastcharts.byspotify.com/api → HTTP 500 sur toutes les | |
| 13 | + variantes (API morte), charts.spotify.com → mur de connexion. Écarté. | |
| 14 | +- ADISQ (palmaresadisq.ca) : SPA JavaScript (pjax renvoie la coquille vide), | |
| 15 | + et un palmarès d'ARTISTES sans liens sociaux → fiches sans lien, rejetées | |
| 16 | + par le schéma. Écarté. | |
| 17 | +- Bible urbaine : articles éditoriaux non structurés (noms en prose, sans | |
| 18 | + handles) → liaison ambiguë interdite (§12). Écarté. | |
| 19 | +- Apple Podcasts : API marketing OFFICIELLE, JSON stable, sans clé. Retenu. | |
| 20 | + | |
| 21 | +Chaîne : top 100 CA (rang = signal de traction) → lookup iTunes GROUPÉ (une | |
| 22 | +requête pour tous les ids : flux RSS, nb d'épisodes, dernier épisode, genres) | |
| 23 | +→ attribution QC conservatrice : marqueur québécois dans le nom/artiste, ou | |
| 24 | +dans la TÊTE du flux RSS auto-déclaré (avant le premier <item> : titre, | |
| 25 | +description, auteur — jamais le contenu des épisodes). Le palmarès étant | |
| 26 | +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 | +de balados-itunes (recherche thématique) — même clé de compte | |
| 29 | +(podcast:collectionId) → fusion naturelle des fiches. | |
| 30 | +""" | |
| 31 | +from __future__ import annotations | |
| 32 | + | |
| 33 | +import re | |
| 34 | +import sys | |
| 35 | +import time | |
| 36 | + | |
| 37 | +from ..identity import account | |
| 38 | +from ..schema import Creator | |
| 39 | +from .base import BaseConnector | |
| 40 | +from .balados_itunes import _GENRE_NICHE, _dormant | |
| 41 | +from .youtube_recherche import is_quebec | |
| 42 | + | |
| 43 | +CHARTS_URL = ("https://rss.marketingtools.apple.com/api/v2/ca/podcasts/" | |
| 44 | + "top/100/podcasts.json") | |
| 45 | +LOOKUP_URL = "https://itunes.apple.com/lookup" | |
| 46 | + | |
| 47 | +_HEAD_BYTES = 40_000 # tête du flux RSS lue pour l'attribution QC | |
| 48 | + | |
| 49 | + | |
| 50 | +def feed_head_is_quebec(head: str) -> bool: | |
| 51 | + """Attribution QC PRUDENTE par l'en-tête du canal RSS (avant <item>). | |
| 52 | + | |
| 53 | + Exige les DEUX : canal francophone (<language> fr…) ET marqueur québécois | |
| 54 | + dans les balises descriptives du canal. Un balado anglo qui mentionne | |
| 55 | + « Montreal » (dates de tournée…) ne passe JAMAIS (constaté 2026-08-18 : | |
| 56 | + Giggly Squad/MeidasTouch dans le top 100 CA) ; un balado QC anglophone | |
| 57 | + sans marqueur au nom est manqué — mieux vaut une fiche de moins qu'une | |
| 58 | + attribution erronée (§7/§15). | |
| 59 | + """ | |
| 60 | + channel = (head or "").split("<item")[0] | |
| 61 | + lang = re.search(r"<language>\s*([A-Za-z-]+)", channel, re.I) | |
| 62 | + if not lang or not lang.group(1).lower().startswith("fr"): | |
| 63 | + return False | |
| 64 | + # balises descriptives du canal uniquement | |
| 65 | + fields = re.findall( | |
| 66 | + r"<(?:title|description|itunes:summary|itunes:author|copyright|" | |
| 67 | + r"managingEditor)[^>]*>(.*?)</", channel, re.S | re.I) | |
| 68 | + return is_quebec(" ".join(fields)) | |
| 69 | + | |
| 70 | + | |
| 71 | +class PalmaresAppleConnector(BaseConnector): | |
| 72 | + source_id = "palmares-apple" | |
| 73 | + kind = "discovery" | |
| 74 | + request_delay = 1.0 | |
| 75 | + | |
| 76 | + def __init__(self) -> None: | |
| 77 | + super().__init__() | |
| 78 | + self.errors = 0 | |
| 79 | + | |
| 80 | + def _chart(self) -> list[dict]: | |
| 81 | + data = self.get(CHARTS_URL).json() | |
| 82 | + return (data.get("feed") or {}).get("results") or [] | |
| 83 | + | |
| 84 | + def _lookup(self, ids: list[str]) -> dict[str, dict]: | |
| 85 | + """Lookup iTunes GROUPÉ (officiel) : une seule requête pour le top.""" | |
| 86 | + resp = self.get(LOOKUP_URL, params={"id": ",".join(ids), | |
| 87 | + "country": "CA"}) | |
| 88 | + return {str(r.get("collectionId")): r | |
| 89 | + for r in resp.json().get("results") or [] | |
| 90 | + if r.get("collectionId")} | |
| 91 | + | |
| 92 | + def _feed_head(self, url: str) -> str: | |
| 93 | + """Début du flux RSS (40 Ko max) — assez pour l'en-tête du canal.""" | |
| 94 | + self._throttle() | |
| 95 | + resp = self.session.get(url, timeout=15, stream=True) | |
| 96 | + try: | |
| 97 | + resp.raise_for_status() | |
| 98 | + chunk = next(resp.iter_content(_HEAD_BYTES), b"") or b"" | |
| 99 | + finally: | |
| 100 | + resp.close() | |
| 101 | + self._last_request = time.time() | |
| 102 | + return chunk.decode("utf-8", "ignore") | |
| 103 | + | |
| 104 | + def fetch(self) -> list[Creator]: | |
| 105 | + try: | |
| 106 | + chart = self._chart() | |
| 107 | + except Exception as exc: | |
| 108 | + print(f"[crea-ka] ⚠ palmares-apple : palmarès indisponible : {exc}", | |
| 109 | + file=sys.stderr) | |
| 110 | + self.errors += 1 | |
| 111 | + return [] | |
| 112 | + details: dict[str, dict] = {} | |
| 113 | + try: | |
| 114 | + details = self._lookup([e["id"] for e in chart if e.get("id")]) | |
| 115 | + except Exception: | |
| 116 | + self.errors += 1 # dégradé : on continue avec le palmarès seul | |
| 117 | + creators: list[Creator] = [] | |
| 118 | + for rank, entry in enumerate(chart, start=1): | |
| 119 | + cid = str(entry.get("id") or "") | |
| 120 | + name = (entry.get("name") or "").strip() | |
| 121 | + artist = (entry.get("artistName") or "").strip() | |
| 122 | + if not cid or not name: | |
| 123 | + continue | |
| 124 | + info = details.get(cid) or {} | |
| 125 | + feed = info.get("feedUrl") or "" | |
| 126 | + # attribution QC conservatrice : nom/artiste, sinon tête du flux | |
| 127 | + quebec = is_quebec(f"{name} {artist}") | |
| 128 | + if not quebec and feed: | |
| 129 | + try: | |
| 130 | + quebec = feed_head_is_quebec(self._feed_head(feed)) | |
| 131 | + except Exception: | |
| 132 | + pass # flux injoignable → pas d'attribution, on passe | |
| 133 | + if not quebec: | |
| 134 | + continue | |
| 135 | + url = entry.get("url") or info.get("collectionViewUrl") or feed | |
| 136 | + if not url: | |
| 137 | + continue | |
| 138 | + acc = account("podcast", cid, "profil_source", url=url) | |
| 139 | + dormant = _dormant(info.get("releaseDate")) | |
| 140 | + acc.metrics = {k: v for k, v in { | |
| 141 | + "chart_rank_ca": rank, # rang du top 100 CA | |
| 142 | + "episodes": info.get("trackCount"), | |
| 143 | + "genre": info.get("primaryGenreName"), | |
| 144 | + "feed_url": feed, | |
| 145 | + "artist_id": info.get("artistId"), | |
| 146 | + "last_episode": info.get("releaseDate"), | |
| 147 | + "dormant": dormant or None, | |
| 148 | + }.items() if v} | |
| 149 | + genres = [g.get("name") for g in (entry.get("genres") or []) | |
| 150 | + if isinstance(g, dict)] | |
| 151 | + niches = sorted({_GENRE_NICHE[g] for g in | |
| 152 | + [info.get("primaryGenreName"), *genres] | |
| 153 | + if g in _GENRE_NICHE}) or ["actualite-opinion"] | |
| 154 | + creators.append(Creator( | |
| 155 | + source=self.source_id, | |
| 156 | + external_id=cid, | |
| 157 | + display_name=name, | |
| 158 | + bio=f"Balado de {artist}." if artist and artist != name else "", | |
| 159 | + niches=niches, | |
| 160 | + creator_type="podcasteur", | |
| 161 | + avatar_url=info.get("artworkUrl600") | |
| 162 | + or entry.get("artworkUrl100"), | |
| 163 | + status="inactive" if dormant else "active", | |
| 164 | + platforms=[acc], | |
| 165 | + notes=f"Top 100 balados Apple Podcasts Canada (rang {rank}).", | |
| 166 | + source_ids=[f"{self.source_id}:{cid}"], | |
| 167 | + )) | |
| 168 | + return creators | |
modified
creaka/connectors/twitch.py
+149 −23
@@ -1,61 +1,108 @@ | ||
| 1 | 1 | # ============================================================================== |
| 2 | 2 | # Author: Simon-Pierre Boucher <contact@spboucher.ai> |
| 3 | 3 | # File: creaka/connectors/twitch.py |
| 4 | −# Desc: Connecteur ENRICHISSEMENT Twitch — existence + profil par créateur | |
| 5 | −# via l'API OFFICIELLE Helix (client_credentials). Palier 2 (§9, §10). | |
| 6 | −# Sans TWITCH_CLIENT_ID/TWITCH_CLIENT_SECRET : passage sauté proprement. | |
| 4 | +# Desc: Connecteur ENRICHISSEMENT Twitch — profil public par créateur SANS | |
| 5 | +# clé via l'API GQL publique du site web officiel (Client-ID public), | |
| 6 | +# abonnés inclus. Voie Helix officielle conservée si des clés | |
| 7 | +# TWITCH_CLIENT_ID/TWITCH_CLIENT_SECRET apparaissent. Palier 2-3 (§9). | |
| 7 | 8 | # ============================================================================== |
| 8 | −"""Enrichissement Twitch via l'API officielle Helix. | |
| 9 | +"""Enrichissement Twitch, créateur par créateur — sans clé (vague 3). | |
| 9 | 10 | |
| 10 | −`users?login=` confirme l'existence du compte, son type (partner/affiliate) | |
| 11 | −et sa description. (Le total d'abonnés de chaîne exige un jeton de modérateur | |
| 12 | −depuis 2023 — on ne le collecte pas plutôt que de contourner, §15.) | |
| 11 | +Le site web officiel de Twitch consomme ``gql.twitch.tv/gql`` avec un | |
| 12 | +Client-ID PUBLIC embarqué dans chaque page servie à n'importe quel visiteur | |
| 13 | +(kimne78kx3ncx6brgo4mv6wki5h1ko) — aucune authentification. On ne lit que les | |
| 14 | +champs publics du profil déjà rattaché (§15) : abonnés (followers.totalCount, | |
| 15 | +que Helix ne fournit plus sans jeton de modérateur depuis 2023), description, | |
| 16 | +avatar, statut partenaire/affilié, dernière diffusion (fraîcheur/dormance). | |
| 17 | +Requêtes REGROUPÉES (20 profils par POST) → ~2 requêtes pour le catalogue | |
| 18 | +Twitch actuel. | |
| 19 | + | |
| 20 | +Si des clés officielles apparaissent dans .env, la voie Helix (privilégiée | |
| 21 | +§10) reprend le dessus pour l'existence/badge, avec le même complément GQL | |
| 22 | +pour les abonnés. | |
| 23 | + | |
| 24 | +⚠ Empreinte TLS : gql.twitch.tv accepte python-requests (vérifié 2026-08-18, | |
| 25 | +HTTP 200) — contrairement à syndication.twitter.com (voir x_profil). Un repli | |
| 26 | +curl-binaire est prévu au cas où un blocage apparaîtrait. | |
| 13 | 27 | """ |
| 14 | 28 | from __future__ import annotations |
| 15 | 29 | |
| 30 | +import json | |
| 16 | 31 | import os |
| 32 | +import subprocess | |
| 33 | +import time | |
| 17 | 34 | |
| 18 | 35 | from ..schema import Creator, now_iso |
| 19 | −from .base import BaseConnector, SkipSource | |
| 36 | +from .base import USER_AGENT, BaseConnector | |
| 20 | 37 | |
| 21 | 38 | TOKEN_URL = "https://id.twitch.tv/oauth2/token" |
| 22 | 39 | USERS_URL = "https://api.twitch.tv/helix/users" |
| 40 | +GQL_URL = "https://gql.twitch.tv/gql" | |
| 41 | + | |
| 42 | +# Client-ID PUBLIC du client web officiel de Twitch (embarqué dans les pages | |
| 43 | +# servies à tous les visiteurs — ce n'est PAS un secret) | |
| 44 | +WEB_CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko" | |
| 45 | + | |
| 46 | +# champs publics du profil (§15 : rien de privé, rien de contourné) | |
| 47 | +_GQL_FIELDS = ("id login displayName description profileImageURL(width: 300) " | |
| 48 | + "followers { totalCount } roles { isPartner isAffiliate } " | |
| 49 | + "lastBroadcast { startedAt game { displayName } } createdAt") | |
| 50 | + | |
| 51 | + | |
| 52 | +def gql_user_query(handle: str) -> dict: | |
| 53 | + """Requête GQL publique pour UN login (échappé via json.dumps).""" | |
| 54 | + return {"query": "query { user(login: %s) { %s } }" | |
| 55 | + % (json.dumps(handle), _GQL_FIELDS)} | |
| 56 | + | |
| 57 | + | |
| 58 | +def parse_gql_user(entry: dict) -> dict | None: | |
| 59 | + """Une réponse GQL → dict plat {followers, partner, affiliate, bio, | |
| 60 | + avatar, last_stream, game} — None si le compte n'existe pas/plus.""" | |
| 61 | + user = (entry.get("data") or {}).get("user") | |
| 62 | + if not user: | |
| 63 | + return None | |
| 64 | + last = user.get("lastBroadcast") or {} | |
| 65 | + roles = user.get("roles") or {} | |
| 66 | + return { | |
| 67 | + "followers": (user.get("followers") or {}).get("totalCount"), | |
| 68 | + "partner": bool(roles.get("isPartner")), | |
| 69 | + "affiliate": bool(roles.get("isAffiliate")), | |
| 70 | + "bio": (user.get("description") or "").strip(), | |
| 71 | + "avatar": user.get("profileImageURL") or "", | |
| 72 | + "last_stream": last.get("startedAt"), | |
| 73 | + "game": (last.get("game") or {}).get("displayName"), | |
| 74 | + } | |
| 23 | 75 | |
| 24 | 76 | |
| 25 | 77 | class TwitchConnector(BaseConnector): |
| 26 | 78 | source_id = "twitch" |
| 27 | 79 | kind = "enrichment" |
| 28 | − request_delay = 0.5 | |
| 80 | + request_delay = 1.0 | |
| 29 | 81 | max_profiles = 400 |
| 82 | + batch_size = 20 # profils par POST GQL (le site en groupe autant) | |
| 30 | 83 | |
| 84 | + def __init__(self) -> None: | |
| 85 | + super().__init__() | |
| 86 | + self.errors = 0 | |
| 87 | + | |
| 88 | + # -- voie officielle Helix (si clés fournies) ------------------------------ | |
| 31 | 89 | def _token(self, cid: str, secret: str) -> str: |
| 32 | 90 | resp = self.post(TOKEN_URL, data={"client_id": cid, |
| 33 | 91 | "client_secret": secret, |
| 34 | 92 | "grant_type": "client_credentials"}) |
| 35 | 93 | return resp.json()["access_token"] |
| 36 | 94 | |
| 37 | − def enrich(self, creators: list[Creator]) -> list[Creator]: | |
| 38 | − cid = os.environ.get("TWITCH_CLIENT_ID", "") | |
| 39 | − secret = os.environ.get("TWITCH_CLIENT_SECRET", "") | |
| 40 | − if not cid or not secret: | |
| 41 | − # pas de clés → passage sauté PROPREMENT (jamais de contournement, | |
| 42 | − # §15) ; SkipSource évite la fausse alerte « source bloquée » | |
| 43 | − raise SkipSource("clés TWITCH_CLIENT_ID/TWITCH_CLIENT_SECRET " | |
| 44 | − "manquantes (.env)") | |
| 95 | + def _enrich_helix(self, pairs: list, cid: str, secret: str) -> list[Creator]: | |
| 45 | 96 | token = self._token(cid, secret) |
| 46 | 97 | headers = {"Client-Id": cid, "Authorization": f"Bearer {token}"} |
| 47 | 98 | enriched: list[Creator] = [] |
| 48 | − fetched = 0 | |
| 49 | − for cr in creators: | |
| 50 | − tw = next((a for a in cr.platforms if a.platform == "twitch"), None) | |
| 51 | − if tw is None or fetched >= self.max_profiles: | |
| 52 | − continue | |
| 99 | + for cr, tw in pairs: | |
| 53 | 100 | try: |
| 54 | 101 | resp = self.get(USERS_URL, params={"login": tw.handle}, |
| 55 | 102 | headers=headers) |
| 56 | 103 | except Exception: |
| 104 | + self.errors += 1 | |
| 57 | 105 | continue |
| 58 | − fetched += 1 | |
| 59 | 106 | data = resp.json().get("data") or [] |
| 60 | 107 | if not data: |
| 61 | 108 | continue |
@@ -66,5 +113,84 @@ class TwitchConnector(BaseConnector): | ||
| 66 | 113 | tw.last_checked = now_iso() |
| 67 | 114 | if not cr.bio and user.get("description"): |
| 68 | 115 | cr.bio = user["description"] |
| 116 | + if not cr.avatar_url and user.get("profile_image_url"): | |
| 117 | + cr.avatar_url = user["profile_image_url"] | |
| 69 | 118 | enriched.append(cr) |
| 70 | 119 | return enriched |
| 120 | + | |
| 121 | + # -- voie publique GQL (sans clé) ------------------------------------------- | |
| 122 | + def _gql(self, handles: list[str]) -> list[dict]: | |
| 123 | + """POST groupé — repli curl-binaire si l'empreinte TLS était refusée.""" | |
| 124 | + payload = [gql_user_query(h) for h in handles] | |
| 125 | + try: | |
| 126 | + resp = self.post(GQL_URL, json=payload, | |
| 127 | + headers={"Client-Id": WEB_CLIENT_ID}) | |
| 128 | + return resp.json() | |
| 129 | + except Exception: | |
| 130 | + pass # 4xx/blocage → repli curl (même pattern que x-profil) | |
| 131 | + self._throttle() | |
| 132 | + try: | |
| 133 | + proc = subprocess.run( | |
| 134 | + ["curl", "-sS", "--fail", "--max-time", str(self.timeout), | |
| 135 | + "-A", USER_AGENT, "-H", f"Client-Id: {WEB_CLIENT_ID}", | |
| 136 | + "-H", "Content-Type: application/json", | |
| 137 | + "-d", json.dumps(payload), GQL_URL], | |
| 138 | + capture_output=True, text=True, timeout=self.timeout + 5) | |
| 139 | + finally: | |
| 140 | + self._last_request = time.time() | |
| 141 | + if proc.returncode != 0: | |
| 142 | + raise RuntimeError(f"curl {proc.returncode}: gql.twitch.tv") | |
| 143 | + return json.loads(proc.stdout) | |
| 144 | + | |
| 145 | + def _enrich_gql(self, pairs: list) -> list[Creator]: | |
| 146 | + enriched: list[Creator] = [] | |
| 147 | + for i in range(0, len(pairs), self.batch_size): | |
| 148 | + batch = pairs[i:i + self.batch_size] | |
| 149 | + try: | |
| 150 | + replies = self._gql([tw.handle for _, tw in batch]) | |
| 151 | + except Exception: | |
| 152 | + self.errors += 1 | |
| 153 | + continue | |
| 154 | + if not isinstance(replies, list) or len(replies) != len(batch): | |
| 155 | + self.errors += 1 | |
| 156 | + continue | |
| 157 | + for (cr, tw), entry in zip(batch, replies): | |
| 158 | + info = parse_gql_user(entry if isinstance(entry, dict) else {}) | |
| 159 | + tw.last_checked = now_iso() | |
| 160 | + if info is None: # compte disparu/renommé : horodaté, sans plus | |
| 161 | + enriched.append(cr) | |
| 162 | + continue | |
| 163 | + if info["followers"] is not None: | |
| 164 | + tw.followers = int(info["followers"]) | |
| 165 | + if info["partner"]: | |
| 166 | + tw.verified = True | |
| 167 | + tw.metrics.update({k: v for k, v in { | |
| 168 | + "affiliate": info["affiliate"] or None, | |
| 169 | + "last_stream": info["last_stream"], | |
| 170 | + "game": info["game"], | |
| 171 | + }.items() if v}) | |
| 172 | + if not cr.bio and info["bio"]: | |
| 173 | + cr.bio = info["bio"] | |
| 174 | + if not cr.avatar_url and info["avatar"].startswith("http"): | |
| 175 | + cr.avatar_url = info["avatar"] | |
| 176 | + enriched.append(cr) | |
| 177 | + return enriched | |
| 178 | + | |
| 179 | + def enrich(self, creators: list[Creator]) -> list[Creator]: | |
| 180 | + pairs = [] | |
| 181 | + for cr in creators: | |
| 182 | + tw = next((a for a in cr.platforms if a.platform == "twitch"), None) | |
| 183 | + if tw is not None: | |
| 184 | + pairs.append((cr, tw)) | |
| 185 | + pairs = pairs[:self.max_profiles] | |
| 186 | + if not pairs: | |
| 187 | + return [] | |
| 188 | + cid = os.environ.get("TWITCH_CLIENT_ID", "") | |
| 189 | + secret = os.environ.get("TWITCH_CLIENT_SECRET", "") | |
| 190 | + if cid and secret: # voie officielle privilégiée (§10) quand disponible | |
| 191 | + enriched = self._enrich_helix(pairs, cid, secret) | |
| 192 | + extra = self._enrich_gql(pairs) # complément : abonnés (GQL public) | |
| 193 | + seen = {id(c) for c in enriched} | |
| 194 | + enriched += [c for c in extra if id(c) not in seen] | |
| 195 | + return enriched | |
| 196 | + return self._enrich_gql(pairs) | |
added
creaka/connectors/wikipedia_qc.py
+236 −0
@@ -0,0 +1,236 @@ | ||
| 1 | +# ============================================================================== | |
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +# File: creaka/connectors/wikipedia_qc.py | |
| 4 | +# Desc: Connecteur DÉCOUVERTE — créateurs de contenu québécois recensés dans | |
| 5 | +# les CATÉGORIES de Wikipédia (fr + en), handles sociaux CURÉS tirés | |
| 6 | +# de l'item Wikidata de chaque article. Mode d'accès : API MediaWiki | |
| 7 | +# publique (action=query) + API Wikidata (wbgetentities) — sans clé, | |
| 8 | +# très fiable. Palier 3 (§9). Vague 3. | |
| 9 | +# ============================================================================== | |
| 10 | +"""Découverte via les catégories Wikipédia de créateurs québécois. | |
| 11 | + | |
| 12 | +Complète wikidata-qc (requêtes SPARQL par occupation+lieu) : les CATÉGORIES | |
| 13 | +éditoriales de Wikipédia (« Vidéaste web canadien », « Humoriste québécois », | |
| 14 | +« Canadian YouTubers »…) recensent des créateurs dont l'item Wikidata n'a pas | |
| 15 | +toujours l'occupation ou le lieu bien remplis — mais dont l'article, lui, est | |
| 16 | +catégorisé par la communauté. Chaîne : membres de catégorie → intro d'article | |
| 17 | +(marqueur QUÉBÉCOIS OBLIGATOIRE pour les catégories pan-canadiennes, §7/§15) | |
| 18 | +→ item Wikidata → handles sociaux curés (P2003/P7085/P2397/P2002/P5797/P2013) | |
| 19 | ++ site officiel (P856). Signal `base_publique` (0.85). | |
| 20 | + | |
| 21 | +Prudence (§15) : année de naissance publique (P569) → is_minor levé | |
| 22 | +automatiquement ; personnes décédées (P570) exclues (annuaire de créateurs | |
| 23 | +actifs) ; fiche créée SEULEMENT si ≥1 lien social curé existe. | |
| 24 | +""" | |
| 25 | +from __future__ import annotations | |
| 26 | + | |
| 27 | +import sys | |
| 28 | +from datetime import datetime, timezone | |
| 29 | +from urllib.parse import urlparse | |
| 30 | + | |
| 31 | +from ..identity import account | |
| 32 | +from ..normalize import slugify | |
| 33 | +from ..schema import Creator | |
| 34 | +from .base import BaseConnector | |
| 35 | +from .youtube_recherche import is_quebec | |
| 36 | + | |
| 37 | +WIKI_API = "https://{lang}.wikipedia.org/w/api.php" | |
| 38 | +WIKIDATA_API = "https://www.wikidata.org/w/api.php" | |
| 39 | + | |
| 40 | +# (langue, catégorie, marqueur QC requis dans l'intro ?, niche, type) | |
| 41 | +CATEGORIES: list[tuple[str, str, bool, str, str]] = [ | |
| 42 | + ("fr", "Catégorie:Vidéaste web canadien", True, "lifestyle", "youtubeur"), | |
| 43 | + ("fr", "Catégorie:Humoriste québécois", False, "humour", "humoriste"), | |
| 44 | + ("fr", "Catégorie:Blogueur québécois", False, "actualite-opinion", | |
| 45 | + "createur-ecrit"), | |
| 46 | + ("fr", "Catégorie:Podcasteur", True, "actualite-opinion", "podcasteur"), | |
| 47 | + ("fr", "Catégorie:Podcasteuse", True, "actualite-opinion", "podcasteur"), | |
| 48 | + ("en", "Category:YouTubers from Quebec", False, "lifestyle", "youtubeur"), | |
| 49 | + ("en", "Category:Canadian YouTubers", True, "lifestyle", "youtubeur"), | |
| 50 | + ("en", "Category:Canadian podcasters", True, "actualite-opinion", | |
| 51 | + "podcasteur"), | |
| 52 | + ("en", "Category:Canadian Twitch (service) streamers", True, "gaming", | |
| 53 | + "streamer"), | |
| 54 | + ("en", "Category:Canadian TikTokers", True, "lifestyle", "createur-tiktok"), | |
| 55 | +] | |
| 56 | + | |
| 57 | +# propriétés Wikidata → plateformes §6.2 (handles curés par la communauté) | |
| 58 | +_PROP_PLATFORM = { | |
| 59 | + "P2003": "instagram", "P7085": "tiktok", "P2002": "x", | |
| 60 | + "P5797": "twitch", "P2013": "facebook", | |
| 61 | +} | |
| 62 | +_YT_CHANNEL_PROP = "P2397" # ID de chaîne YouTube (UC…) → URL directe | |
| 63 | +_WEBSITE_PROP = "P856" # site officiel → compte site-web | |
| 64 | +_BIRTH_PROP, _DEATH_PROP = "P569", "P570" | |
| 65 | + | |
| 66 | + | |
| 67 | +def claim_values(claims: dict, prop: str) -> list: | |
| 68 | + """Valeurs d'une propriété Wikidata (mainsnak.datavalue.value).""" | |
| 69 | + out = [] | |
| 70 | + for st in claims.get(prop) or []: | |
| 71 | + dv = (st.get("mainsnak") or {}).get("datavalue") | |
| 72 | + if dv is not None and dv.get("value") is not None: | |
| 73 | + out.append(dv["value"]) | |
| 74 | + return out | |
| 75 | + | |
| 76 | + | |
| 77 | +def accounts_from_claims(claims: dict) -> list: | |
| 78 | + """Claims Wikidata → comptes PlatformAccount (signal base_publique).""" | |
| 79 | + accounts = [] | |
| 80 | + for prop, platform in _PROP_PLATFORM.items(): | |
| 81 | + for handle in claim_values(claims, prop): | |
| 82 | + if isinstance(handle, str) and handle.strip(): | |
| 83 | + accounts.append(account(platform, handle.strip(), | |
| 84 | + "base_publique")) | |
| 85 | + for cid in claim_values(claims, _YT_CHANNEL_PROP): | |
| 86 | + if isinstance(cid, str) and cid.strip(): | |
| 87 | + accounts.append(account( | |
| 88 | + "youtube", cid.strip(), "base_publique", | |
| 89 | + url=f"https://www.youtube.com/channel/{cid.strip()}")) | |
| 90 | + for url in claim_values(claims, _WEBSITE_PROP): | |
| 91 | + if isinstance(url, str) and url.startswith("http"): | |
| 92 | + domain = urlparse(url).netloc.lower().removeprefix("www.") | |
| 93 | + if domain: | |
| 94 | + accounts.append(account("site-web", domain, "base_publique", | |
| 95 | + url=url)) | |
| 96 | + return accounts | |
| 97 | + | |
| 98 | + | |
| 99 | +def birth_year(claims: dict) -> int | None: | |
| 100 | + for v in claim_values(claims, _BIRTH_PROP): | |
| 101 | + t = v.get("time") if isinstance(v, dict) else None | |
| 102 | + if t and len(t) >= 5: | |
| 103 | + try: | |
| 104 | + return int(t[1:5]) | |
| 105 | + except ValueError: | |
| 106 | + continue | |
| 107 | + return None | |
| 108 | + | |
| 109 | + | |
| 110 | +def is_deceased(claims: dict) -> bool: | |
| 111 | + return bool(claims.get(_DEATH_PROP)) | |
| 112 | + | |
| 113 | + | |
| 114 | +class WikipediaQcConnector(BaseConnector): | |
| 115 | + source_id = "wikipedia-qc" | |
| 116 | + kind = "discovery" | |
| 117 | + request_delay = 1.0 | |
| 118 | + timeout = 45 | |
| 119 | + batch_extracts = 20 # exlimit max de prop=extracts avec exintro | |
| 120 | + batch_entities = 50 # wbgetentities max sans droit bot | |
| 121 | + | |
| 122 | + def __init__(self) -> None: | |
| 123 | + super().__init__() | |
| 124 | + self.errors = 0 | |
| 125 | + | |
| 126 | + def _members(self, lang: str, category: str) -> list[dict]: | |
| 127 | + """Tous les articles (ns=0) d'une catégorie, avec pagination.""" | |
| 128 | + members, cont = [], {} | |
| 129 | + while True: | |
| 130 | + params = {"action": "query", "list": "categorymembers", | |
| 131 | + "cmtitle": category, "cmtype": "page", | |
| 132 | + "cmlimit": "500", "format": "json", **cont} | |
| 133 | + data = self.get(WIKI_API.format(lang=lang), params=params).json() | |
| 134 | + members += [m for m in | |
| 135 | + data.get("query", {}).get("categorymembers", []) | |
| 136 | + if m.get("ns") == 0] | |
| 137 | + cont = data.get("continue") or {} | |
| 138 | + if not cont: | |
| 139 | + return members | |
| 140 | + | |
| 141 | + def _pages(self, lang: str, pageids: list[int]) -> list[dict]: | |
| 142 | + """Intro (2 phrases) + item Wikidata + image, par lots de 20.""" | |
| 143 | + pages: list[dict] = [] | |
| 144 | + for i in range(0, len(pageids), self.batch_extracts): | |
| 145 | + batch = pageids[i:i + self.batch_extracts] | |
| 146 | + data = self.get(WIKI_API.format(lang=lang), params={ | |
| 147 | + "action": "query", "pageids": "|".join(map(str, batch)), | |
| 148 | + "prop": "extracts|pageprops|pageimages", | |
| 149 | + "exintro": "1", "explaintext": "1", "exsentences": "3", | |
| 150 | + "ppprop": "wikibase_item", | |
| 151 | + "piprop": "thumbnail", "pithumbsize": "400", | |
| 152 | + "format": "json"}).json() | |
| 153 | + pages += list(data.get("query", {}).get("pages", {}).values()) | |
| 154 | + return pages | |
| 155 | + | |
| 156 | + def _entities(self, qids: list[str]) -> dict: | |
| 157 | + """Claims Wikidata par QID, par lots de 50.""" | |
| 158 | + entities: dict = {} | |
| 159 | + for i in range(0, len(qids), self.batch_entities): | |
| 160 | + batch = qids[i:i + self.batch_entities] | |
| 161 | + data = self.get(WIKIDATA_API, params={ | |
| 162 | + "action": "wbgetentities", "ids": "|".join(batch), | |
| 163 | + "props": "claims", "format": "json"}).json() | |
| 164 | + entities.update(data.get("entities") or {}) | |
| 165 | + return entities | |
| 166 | + | |
| 167 | + def fetch(self) -> list[Creator]: | |
| 168 | + year_now = datetime.now(timezone.utc).year | |
| 169 | + # QID → fiche en construction (une personne peut être dans 2+ catégories) | |
| 170 | + drafts: dict[str, dict] = {} | |
| 171 | + for lang, category, need_qc, niche, ctype in CATEGORIES: | |
| 172 | + try: | |
| 173 | + members = self._members(lang, category) | |
| 174 | + pages = self._pages(lang, [m["pageid"] for m in members]) | |
| 175 | + except Exception as exc: | |
| 176 | + # une catégorie en échec ne bloque pas les autres — jamais en | |
| 177 | + # silence (§16/§18) : la couverture chuterait sans trace | |
| 178 | + self.errors += 1 | |
| 179 | + print(f"[crea-ka] ⚠ wikipedia-qc : {lang}:{category} en " | |
| 180 | + f"échec : {exc}", file=sys.stderr) | |
| 181 | + continue | |
| 182 | + for p in pages: | |
| 183 | + qid = (p.get("pageprops") or {}).get("wikibase_item") | |
| 184 | + title = (p.get("title") or "").strip() | |
| 185 | + extract = (p.get("extract") or "").strip() | |
| 186 | + if not qid or not title: | |
| 187 | + continue | |
| 188 | + # attribution prudente (§7/§15) : catégorie pan-canadienne → | |
| 189 | + # marqueur québécois OBLIGATOIRE dans l'intro de l'article | |
| 190 | + if need_qc and not is_quebec(extract): | |
| 191 | + continue | |
| 192 | + d = drafts.setdefault(qid, { | |
| 193 | + "name": title.split(" (")[0], "bio": "", "avatar": None, | |
| 194 | + "niches": set(), "types": [], "langs": set()}) | |
| 195 | + d["niches"].add(niche) | |
| 196 | + d["types"].append(ctype) | |
| 197 | + d["langs"].add(lang) | |
| 198 | + if len(extract) > len(d["bio"]): | |
| 199 | + d["bio"] = extract | |
| 200 | + thumb = ((p.get("thumbnail") or {}).get("source")) | |
| 201 | + if thumb and not d["avatar"]: | |
| 202 | + d["avatar"] = thumb | |
| 203 | + if not drafts: | |
| 204 | + return [] | |
| 205 | + try: | |
| 206 | + entities = self._entities(sorted(drafts)) | |
| 207 | + except Exception as exc: | |
| 208 | + self.errors += 1 | |
| 209 | + print(f"[crea-ka] ⚠ wikipedia-qc : wbgetentities en échec : {exc}", | |
| 210 | + file=sys.stderr) | |
| 211 | + return [] | |
| 212 | + creators: list[Creator] = [] | |
| 213 | + for qid, d in drafts.items(): | |
| 214 | + claims = (entities.get(qid) or {}).get("claims") or {} | |
| 215 | + if is_deceased(claims): | |
| 216 | + continue # annuaire de créateurs actifs | |
| 217 | + accounts = accounts_from_claims(claims) | |
| 218 | + if not accounts: | |
| 219 | + continue # un annuaire de LIENS : pas de fiche sans lien | |
| 220 | + birth = birth_year(claims) | |
| 221 | + is_minor = bool(birth and (year_now - birth) < 18) | |
| 222 | + creators.append(Creator( | |
| 223 | + source=self.source_id, | |
| 224 | + external_id=qid, | |
| 225 | + display_name=d["name"], | |
| 226 | + bio=d["bio"][:1200], | |
| 227 | + niches=sorted(d["niches"]) or ["autre"], | |
| 228 | + creator_type=d["types"][0], | |
| 229 | + avatar_url=d["avatar"], | |
| 230 | + is_minor=is_minor, | |
| 231 | + platforms=accounts, | |
| 232 | + notes="Article Wikipédia (catégories de créateurs québécois).", | |
| 233 | + source_ids=[f"{self.source_id}:{qid}"], | |
| 234 | + )) | |
| 235 | + # tri stable pour des passages reproductibles | |
| 236 | + return sorted(creators, key=lambda c: slugify(c.display_name)) | |
modified
creaka/connectors/youtube.py
+255 −25
@@ -1,23 +1,36 @@ | ||
| 1 | 1 | # ============================================================================== |
| 2 | 2 | # Author: Simon-Pierre Boucher <contact@spboucher.ai> |
| 3 | 3 | # File: creaka/connectors/youtube.py |
| 4 | −# Desc: Connecteur ENRICHISSEMENT YouTube — abonnés + badge par créateur. | |
| 4 | +# Desc: Connecteur ENRICHISSEMENT YouTube — stats de chaîne par créateur. | |
| 5 | 5 | # Mode d'accès : YouTube Data API v3 (OFFICIELLE, privilégiée §10) si |
| 6 | −# YOUTUBE_API_KEY est définie ; sinon repli page publique /@handle | |
| 7 | −# (requêtes directes polies). Palier 2 du catalogue (§9). | |
| 6 | +# YOUTUBE_API_KEY est définie ; sinon voie SANS clé complète (vague 3) : | |
| 7 | +# page publique de la chaîne + InnerTube « browse » (l'API interne que | |
| 8 | +# la page elle-même appelle, clé publique embarquée). Palier 2-3 (§9). | |
| 8 | 9 | # ============================================================================== |
| 9 | 10 | """Enrichissement YouTube, créateur par créateur. |
| 10 | 11 | |
| 11 | 12 | Voie privilégiée : l'API officielle `channels?forHandle=` (fiable, conforme, |
| 12 | −quota 10 000 unités/jour largement suffisant pour la cadence §14). Sans clé, | |
| 13 | −repli léger : la page publique youtube.com/@handle expose le nombre d'abonnés | |
| 14 | −dans ses métadonnées. Signal : `api_officielle` (0.95) quand confirmé par | |
| 15 | −l'API — le handle interrogé est celui déjà rattaché. | |
| 13 | +quota 10 000 unités/jour). Sans clé, voie publique complète (vague 3) : | |
| 14 | + | |
| 15 | +1. page ``youtube.com/@handle`` (ytInitialData) → abonnés, nb de vidéos, | |
| 16 | + description (→ bio si vide), bannière, avatar, id de chaîne UC…, et le | |
| 17 | + jeton du panneau « À propos » ; | |
| 18 | +2. InnerTube ``browse`` avec ce jeton (la requête que le navigateur fait en | |
| 19 | + cliquant « …plus ») → vues totales, nb de vidéos exact, pays affiché ; | |
| 20 | +3. page ``/videos`` → date approximative de la dernière vidéo (« il y a X ») | |
| 21 | + → fraîcheur/dormance (metrics.last_video + dormant >12 mois). | |
| 22 | + | |
| 23 | +Cap 300 chaînes/passage avec ROTATION (metrics.yt_checked, re-visite 14 j, | |
| 24 | +jamais-lues d'abord) : les ~4 200 comptes YouTube convergent en ~2 semaines. | |
| 25 | +Signal : `api_officielle` (0.95) quand confirmé par l'API — le handle | |
| 26 | +interrogé est celui déjà rattaché ; la voie publique ne touche pas au score. | |
| 16 | 27 | """ |
| 17 | 28 | from __future__ import annotations |
| 18 | 29 | |
| 30 | +import json | |
| 19 | 31 | import os |
| 20 | 32 | import re |
| 33 | +from datetime import datetime, timedelta, timezone | |
| 21 | 34 | |
| 22 | 35 | from ..normalize import parse_count |
| 23 | 36 | from ..schema import Creator, now_iso |
@@ -26,20 +39,132 @@ from .base import BaseConnector | ||
| 26 | 39 | API_URL = ("https://www.googleapis.com/youtube/v3/channels" |
| 27 | 40 | "?part=statistics,snippet&forHandle={h}&key={key}") |
| 28 | 41 | PAGE_URL = "https://www.youtube.com/@{h}" |
| 42 | +CHANNEL_URL = "https://www.youtube.com/channel/{h}" | |
| 43 | +BROWSE_URL = "https://www.youtube.com/youtubei/v1/browse" | |
| 44 | + | |
| 45 | +_YTID_RE = re.compile(r"var ytInitialData = (\{.*?\});</script>", re.S) | |
| 46 | +_KEY_RE = re.compile(r'"INNERTUBE_API_KEY":"([^"]+)"') | |
| 47 | +_VER_RE = re.compile(r'"INNERTUBE_CONTEXT_CLIENT_VERSION":"([^"]+)"') | |
| 48 | +_UCID_RE = re.compile(r"UC[0-9A-Za-z_-]{22}") | |
| 29 | 49 | _SUBS_RES = ( |
| 30 | 50 | re.compile(r'"subscriberCountText"[^}]*?"(?:simpleText|content)"\s*:\s*"([^"]+)"'), |
| 31 | 51 | re.compile(r'"subscriberCount"\s*:\s*"?(\d+)"?'), |
| 32 | − re.compile(r'"metadataParts".{0,200}?"content"\s*:\s*"([\d.,\s]+[kKmM]?\s*' | |
| 33 | − r'(?:subscribers|abonn[ée]s))"'), | |
| 52 | + re.compile(r'"content"\s*:\s*"([\d.,\s]+[kKmMbB]?\s*(?:subscribers|abonn[ée]s))"'), | |
| 34 | 53 | ) |
| 54 | +_VIDEOS_RE = re.compile(r'"content"\s*:\s*"([\d.,\s]+[kKmM]?)\s*videos?"') | |
| 55 | +# première vidéo de l'onglet /videos (tri chronologique) : « 3 weeks ago » | |
| 56 | +_AGO_RE = re.compile(r'"(?:content|simpleText)":"(?:Streamed )?' | |
| 57 | + r'(\d+ (?:second|minute|hour|day|week|month|year)s? ago)"') | |
| 58 | +_AGO_UNITS = {"second": 1, "minute": 60, "hour": 3600, "day": 86400, | |
| 59 | + "week": 7 * 86400, "month": 30 * 86400, "year": 365 * 86400} | |
| 60 | + | |
| 61 | +# au-delà de 12 mois sans vidéo : chaîne dormante (métrique publique, comme | |
| 62 | +# balados-itunes — la fiche reste active, le créateur vit peut-être ailleurs) | |
| 63 | +DORMANT_AFTER_DAYS = 365 | |
| 64 | + | |
| 65 | + | |
| 66 | +def ago_to_iso(text: str, now: datetime | None = None) -> str | None: | |
| 67 | + """« 3 weeks ago » / « Streamed 1 year ago » → date ISO approximative.""" | |
| 68 | + m = re.search(r"(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+ago", | |
| 69 | + text or "") | |
| 70 | + if not m: | |
| 71 | + return None | |
| 72 | + seconds = int(m.group(1)) * _AGO_UNITS[m.group(2)] | |
| 73 | + at = (now or datetime.now(timezone.utc)) - timedelta(seconds=seconds) | |
| 74 | + return at.strftime("%Y-%m-%d") | |
| 75 | + | |
| 76 | + | |
| 77 | +def _walk_token(node) -> str | None: | |
| 78 | + """Premier jeton continuationCommand d'un sous-arbre JSON.""" | |
| 79 | + if isinstance(node, dict): | |
| 80 | + cmd = node.get("continuationCommand") | |
| 81 | + if isinstance(cmd, dict) and cmd.get("token"): | |
| 82 | + return cmd["token"] | |
| 83 | + for v in node.values(): | |
| 84 | + tok = _walk_token(v) | |
| 85 | + if tok: | |
| 86 | + return tok | |
| 87 | + elif isinstance(node, list): | |
| 88 | + for item in node: | |
| 89 | + tok = _walk_token(item) | |
| 90 | + if tok: | |
| 91 | + return tok | |
| 92 | + return None | |
| 93 | + | |
| 94 | + | |
| 95 | +def parse_channel_page(html: str) -> dict | None: | |
| 96 | + """Page publique de la chaîne → {followers, videos, description, avatar, | |
| 97 | + banner, channel_id, about_token, key, ver} (champs manquants = None).""" | |
| 98 | + m = _YTID_RE.search(html) | |
| 99 | + if not m: | |
| 100 | + return None | |
| 101 | + try: | |
| 102 | + data = json.loads(m.group(1)) | |
| 103 | + except ValueError: | |
| 104 | + return None | |
| 105 | + meta = (data.get("metadata") or {}).get("channelMetadataRenderer") or {} | |
| 106 | + out: dict = {"description": (meta.get("description") or "").strip(), | |
| 107 | + "channel_id": meta.get("externalId"), | |
| 108 | + "followers": None, "videos": None, | |
| 109 | + "avatar": None, "banner": None, "about_token": None} | |
| 110 | + thumbs = ((meta.get("avatar") or {}).get("thumbnails")) or [] | |
| 111 | + if thumbs: | |
| 112 | + out["avatar"] = max(thumbs, key=lambda t: t.get("width") or 0).get("url") | |
| 113 | + header = json.dumps((data.get("header") or {}), ensure_ascii=False) | |
| 114 | + for rx in _SUBS_RES: | |
| 115 | + mm = rx.search(header) | |
| 116 | + if mm: | |
| 117 | + out["followers"] = parse_count(mm.group(1)) | |
| 118 | + break | |
| 119 | + mm = _VIDEOS_RE.search(header) | |
| 120 | + if mm: | |
| 121 | + out["videos"] = parse_count(mm.group(1)) | |
| 122 | + vm = ((((data.get("header") or {}).get("pageHeaderRenderer") or {}) | |
| 123 | + .get("content") or {}).get("pageHeaderViewModel") or {}) | |
| 124 | + sources = ((((vm.get("banner") or {}).get("imageBannerViewModel") or {}) | |
| 125 | + .get("image") or {}).get("sources")) or [] | |
| 126 | + if sources: | |
| 127 | + out["banner"] = max(sources, key=lambda s: s.get("width") or 0).get("url") | |
| 128 | + # jeton du panneau « À propos » : sous l'aperçu de description du header | |
| 129 | + # (c'est LA requête que fait le navigateur au clic sur « …plus ») | |
| 130 | + out["about_token"] = _walk_token(vm.get("description")) or \ | |
| 131 | + _walk_token(vm.get("attribution")) | |
| 132 | + key_m, ver_m = _KEY_RE.search(html), _VER_RE.search(html) | |
| 133 | + out["key"] = key_m.group(1) if key_m else None | |
| 134 | + out["ver"] = ver_m.group(1) if ver_m else None | |
| 135 | + return out | |
| 136 | + | |
| 137 | + | |
| 138 | +def parse_about(text: str) -> dict | None: | |
| 139 | + """Réponse browse du panneau À propos → {views, videos, country} ou None.""" | |
| 140 | + if "aboutChannelViewModel" not in (text or ""): | |
| 141 | + return None | |
| 142 | + out: dict = {} | |
| 143 | + mm = re.search(r'"viewCountText"\s*:\s*"([^"]+)"', text) | |
| 144 | + if mm: | |
| 145 | + out["views"] = parse_count(mm.group(1)) | |
| 146 | + mm = re.search(r'"videoCountText"\s*:\s*"([^"]+)"', text) | |
| 147 | + if mm: | |
| 148 | + out["videos"] = parse_count(mm.group(1)) | |
| 149 | + mm = re.search(r'"country"\s*:\s*(?:\{"content":)?"([^"]+)"', text) | |
| 150 | + if mm: | |
| 151 | + out["country"] = mm.group(1) | |
| 152 | + return out | |
| 35 | 153 | |
| 36 | 154 | |
| 37 | 155 | class YouTubeConnector(BaseConnector): |
| 38 | 156 | source_id = "youtube" |
| 39 | 157 | kind = "enrichment" |
| 40 | 158 | request_delay = 1.0 |
| 41 | − max_profiles = 1500 | |
| 159 | + max_profiles = 1500 # voie API officielle (1 requête légère/chaîne) | |
| 160 | + max_profiles_page = 300 # voie publique (2-3 requêtes/chaîne) — rotation | |
| 161 | + revisit_days = 14 | |
| 42 | 162 | |
| 163 | + def __init__(self) -> None: | |
| 164 | + super().__init__() | |
| 165 | + self.errors = 0 | |
| 166 | + | |
| 167 | + # -- voie API officielle ----------------------------------------------------- | |
| 43 | 168 | def _via_api(self, handle: str, key: str) -> dict | None: |
| 44 | 169 | resp = self.get(API_URL.format(h=handle, key=key)) |
| 45 | 170 | items = resp.json().get("items") or [] |
@@ -51,18 +176,72 @@ class YouTubeConnector(BaseConnector): | ||
| 51 | 176 | "videos": parse_count(stats.get("videoCount")), |
| 52 | 177 | "confirmed": True} |
| 53 | 178 | |
| 54 | − def _via_page(self, handle: str) -> dict | None: | |
| 55 | − resp = self.get(PAGE_URL.format(h=handle), | |
| 56 | − headers={"Accept-Language": "en-CA,en;q=0.8"}) | |
| 57 | − html = resp.text | |
| 58 | − for rx in _SUBS_RES: | |
| 59 | − m = rx.search(html) | |
| 60 | − if m: | |
| 61 | − return {"followers": parse_count(m.group(1)), "confirmed": False} | |
| 62 | − return None | |
| 179 | + # -- voie publique sans clé (vague 3) ----------------------------------------- | |
| 180 | + def _channel_url(self, acc) -> str: | |
| 181 | + if _UCID_RE.fullmatch(acc.handle): | |
| 182 | + return CHANNEL_URL.format(h=acc.handle) | |
| 183 | + return PAGE_URL.format(h=acc.handle) | |
| 184 | + | |
| 185 | + def _browse_about(self, key: str, ver: str, token: str) -> dict | None: | |
| 186 | + resp = self.post( | |
| 187 | + BROWSE_URL, params={"key": key}, | |
| 188 | + json={"context": {"client": {"clientName": "WEB", | |
| 189 | + "clientVersion": ver, | |
| 190 | + "hl": "en", "gl": "CA"}}, | |
| 191 | + "continuation": token}, | |
| 192 | + headers={"Accept-Language": "en-CA,en;q=0.8"}) | |
| 193 | + return parse_about(resp.text) | |
| 194 | + | |
| 195 | + def _last_video(self, base_url: str) -> str | None: | |
| 196 | + html = self.get(base_url.rstrip("/") + "/videos", | |
| 197 | + headers={"Accept-Language": "en-CA,en;q=0.8"}).text | |
| 198 | + m = _YTID_RE.search(html) | |
| 199 | + if not m: | |
| 200 | + return None | |
| 201 | + mm = _AGO_RE.search(m.group(1)) | |
| 202 | + return ago_to_iso(mm.group(1)) if mm else None | |
| 203 | + | |
| 204 | + def _via_page(self, acc) -> dict | None: | |
| 205 | + base = self._channel_url(acc) | |
| 206 | + html = self.get(base, headers={"Accept-Language": "en-CA,en;q=0.8"}).text | |
| 207 | + page = parse_channel_page(html) | |
| 208 | + if page is None: | |
| 209 | + return None | |
| 210 | + info: dict = {"followers": page["followers"], "videos": page["videos"], | |
| 211 | + "description": page["description"], "avatar": page["avatar"], | |
| 212 | + "banner": page["banner"], "confirmed": False} | |
| 213 | + if page["about_token"] and page["key"] and page["ver"]: | |
| 214 | + try: # vues totales + nb de vidéos exact « si affichés » | |
| 215 | + about = self._browse_about(page["key"], page["ver"], | |
| 216 | + page["about_token"]) or {} | |
| 217 | + info.update({k: v for k, v in about.items() if v is not None}) | |
| 218 | + except Exception: | |
| 219 | + self.errors += 1 | |
| 220 | + if info.get("videos"): # date de la dernière vidéo (fraîcheur/dormance) | |
| 221 | + try: | |
| 222 | + info["last_video"] = self._last_video(base) | |
| 223 | + except Exception: | |
| 224 | + self.errors += 1 | |
| 225 | + return info | |
| 226 | + | |
| 227 | + def _needs_visit(self, metrics: dict) -> bool: | |
| 228 | + raw = metrics.get("yt_checked") | |
| 229 | + if not raw: | |
| 230 | + return True | |
| 231 | + try: | |
| 232 | + checked = datetime.fromisoformat(raw.replace("Z", "+00:00")) | |
| 233 | + except ValueError: | |
| 234 | + return True | |
| 235 | + return (datetime.now(timezone.utc) - checked | |
| 236 | + > timedelta(days=self.revisit_days)) | |
| 63 | 237 | |
| 64 | 238 | def enrich(self, creators: list[Creator]) -> list[Creator]: |
| 65 | 239 | key = os.environ.get("YOUTUBE_API_KEY", "") |
| 240 | + if key: | |
| 241 | + return self._enrich_api(creators, key) | |
| 242 | + return self._enrich_page(creators) | |
| 243 | + | |
| 244 | + def _enrich_api(self, creators: list[Creator], key: str) -> list[Creator]: | |
| 66 | 245 | enriched: list[Creator] = [] |
| 67 | 246 | fetched = 0 |
| 68 | 247 | for cr in creators: |
@@ -70,9 +249,9 @@ class YouTubeConnector(BaseConnector): | ||
| 70 | 249 | if yt is None or fetched >= self.max_profiles: |
| 71 | 250 | continue |
| 72 | 251 | try: |
| 73 | − info = self._via_api(yt.handle, key) if key \ | |
| 74 | − else self._via_page(yt.handle) | |
| 252 | + info = self._via_api(yt.handle, key) | |
| 75 | 253 | except Exception: |
| 254 | + self.errors += 1 | |
| 76 | 255 | continue |
| 77 | 256 | fetched += 1 |
| 78 | 257 | if not info or info.get("followers") is None: |
@@ -81,9 +260,60 @@ class YouTubeConnector(BaseConnector): | ||
| 81 | 260 | yt.last_checked = now_iso() |
| 82 | 261 | extra = {k: info.get(k) for k in ("views", "videos")} |
| 83 | 262 | yt.metrics.update({k: v for k, v in extra.items() if v is not None}) |
| 84 | − if info.get("confirmed"): | |
| 85 | − # existence confirmée par l'API officielle → signal renforcé | |
| 86 | − yt.confidence = max(yt.confidence or 0, 0.90) | |
| 87 | − yt.signal = yt.signal or "api_officielle" | |
| 263 | + # existence confirmée par l'API officielle → signal renforcé | |
| 264 | + yt.confidence = max(yt.confidence or 0, 0.90) | |
| 265 | + yt.signal = yt.signal or "api_officielle" | |
| 266 | + enriched.append(cr) | |
| 267 | + return enriched | |
| 268 | + | |
| 269 | + def _enrich_page(self, creators: list[Creator]) -> list[Creator]: | |
| 270 | + # rotation : jamais-lues d'abord, puis les plus anciennes re-visites ; | |
| 271 | + # l'appelant (ingest.load_active) trie déjà par portée décroissante | |
| 272 | + candidates = [] | |
| 273 | + for cr in creators: | |
| 274 | + yt = next((a for a in cr.platforms if a.platform == "youtube"), None) | |
| 275 | + if yt is not None and self._needs_visit(yt.metrics): | |
| 276 | + candidates.append((cr, yt)) | |
| 277 | + candidates.sort(key=lambda t: bool(t[1].metrics.get("yt_checked"))) | |
| 278 | + | |
| 279 | + enriched: list[Creator] = [] | |
| 280 | + fetched = 0 | |
| 281 | + for cr, yt in candidates: | |
| 282 | + if fetched >= self.max_profiles_page: | |
| 283 | + break | |
| 284 | + fetched += 1 # le cap borne les TENTATIVES réseau | |
| 285 | + try: | |
| 286 | + info = self._via_page(yt) | |
| 287 | + except Exception: | |
| 288 | + self.errors += 1 | |
| 289 | + continue | |
| 290 | + yt.metrics["yt_checked"] = now_iso() | |
| 291 | + if info is None: # chaîne fermée/renommée : horodatée, sans plus | |
| 292 | + enriched.append(cr) | |
| 293 | + continue | |
| 294 | + if info.get("followers") is not None: | |
| 295 | + yt.followers = info["followers"] | |
| 296 | + yt.last_checked = now_iso() | |
| 297 | + dormant = None | |
| 298 | + if info.get("last_video"): | |
| 299 | + try: | |
| 300 | + last = datetime.strptime(info["last_video"], "%Y-%m-%d") | |
| 301 | + dormant = (datetime.now(timezone.utc) | |
| 302 | + - last.replace(tzinfo=timezone.utc) | |
| 303 | + > timedelta(days=DORMANT_AFTER_DAYS)) or None | |
| 304 | + except ValueError: | |
| 305 | + pass | |
| 306 | + yt.metrics.update({k: v for k, v in { | |
| 307 | + "videos": info.get("videos"), | |
| 308 | + "views": info.get("views"), | |
| 309 | + "last_video": info.get("last_video"), | |
| 310 | + "dormant": dormant, | |
| 311 | + "country": info.get("country"), | |
| 312 | + "banner": info.get("banner"), | |
| 313 | + }.items() if v is not None}) | |
| 314 | + if not cr.bio and info.get("description"): | |
| 315 | + cr.bio = info["description"][:1200] | |
| 316 | + if not cr.avatar_url and (info.get("avatar") or "").startswith("http"): | |
| 317 | + cr.avatar_url = info["avatar"] | |
| 88 | 318 | enriched.append(cr) |
| 89 | 319 | return enriched |
modified
creaka/connectors/youtube_recherche.py
+7 −5
@@ -34,12 +34,14 @@ _KEY_RE = re.compile(r'"INNERTUBE_API_KEY":"([^"]+)"') | ||
| 34 | 34 | _VER_RE = re.compile(r'"INNERTUBE_CONTEXT_CLIENT_VERSION":"([^"]+)"') |
| 35 | 35 | |
| 36 | 36 | # marqueurs québécois (comparés SANS accents, en minuscules) |
| 37 | +# ⚠ frontières de mot sur les toponymes courts/piégeux : « levis » matchait | |
| 38 | +# dans « teLEVISion », « estrie » dans « tapESTRIEs » (corrigé vague 3) | |
| 37 | 39 | _QC_MARKERS = re.compile( |
| 38 | − r"queb|montreal|\bmtl\b|saguenay|gaspesie|mauricie|estrie|outaouais|" | |
| 39 | − r"abitibi|levis|sherbrooke|trois-rivieres|gatineau|\blaval\b|longueuil|" | |
| 40 | − r"rimouski|chicoutimi|drummondville|granby|beauce|charlevoix|lanaudiere|" | |
| 41 | − r"laurentides|monteregie|temiscamingue|joliette|victoriaville|" | |
| 42 | − r"\bfleuve\b|rive-sud|rive-nord|ville de quebec") | |
| 40 | + r"queb|montreal|\bmtl\b|saguenay|gaspesie|mauricie|\bestrie\b|outaouais|" | |
| 41 | + r"abitibi|\blevis\b|sherbrooke|trois-rivieres|gatineau|\blaval\b|longueuil|" | |
| 42 | + r"rimouski|chicoutimi|drummondville|\bgranby\b|\bbeauce\b|charlevoix|" | |
| 43 | + r"lanaudiere|laurentides|monteregie|temiscamingue|\bjoliette\b|" | |
| 44 | + r"victoriaville|\bfleuve\b|rive-sud|rive-nord|ville de quebec") | |
| 43 | 45 | |
| 44 | 46 | # requêtes thématiques → niche par défaut de la fiche |
| 45 | 47 | QUERIES: list[tuple[str, str]] = [ |
modified
data/sources.json
+25 −7
@@ -40,6 +40,24 @@ | ||
| 40 | 40 | "cadence": "quotidienne (watch) — la couverture s'accumule à chaque passage", |
| 41 | 41 | "notes": "filtre conservateur : marqueur québécois requis dans titre/description de la chaîne" |
| 42 | 42 | }, |
| 43 | + { | |
| 44 | + "id": "wikipedia-qc", | |
| 45 | + "famille": "decouverte", | |
| 46 | + "palier": 3, | |
| 47 | + "acces": "API MediaWiki publique (fr+en, catégories de créateurs QC : Vidéaste web canadien, Humoriste/Blogueur québécois, Canadian YouTubers/podcasters/Twitch streamers/TikTokers…) + API Wikidata wbgetentities (handles sociaux curés de l'infobox) — sans clé", | |
| 48 | + "signal_identite": "base_publique (0.85)", | |
| 49 | + "cadence": "hebdomadaire", | |
| 50 | + "notes": "vague 3 — complète wikidata-qc (SPARQL) : catégories éditoriales → intro d'article (marqueur QC OBLIGATOIRE pour les catégories pan-canadiennes) → handles P2003/P7085/P2397/P2002/P5797/P2013 + site officiel P856 ; personnes décédées exclues, is_minor via P569, fiche seulement si ≥1 lien curé" | |
| 51 | + }, | |
| 52 | + { | |
| 53 | + "id": "palmares-apple", | |
| 54 | + "famille": "decouverte", | |
| 55 | + "palier": 2, | |
| 56 | + "acces": "API marketing OFFICIELLE d'Apple (rss.marketingtools.apple.com, top 100 podcasts Canada, sans clé) + lookup iTunes groupé + tête de flux RSS pour l'attribution QC", | |
| 57 | + "signal_identite": "profil_source (0.98)", | |
| 58 | + "cadence": "quotidienne (watch) — le palmarès bouge chaque jour", | |
| 59 | + "notes": "vague 3 — balados QC qui percent dans le top 100 CA (metrics.chart_rank_ca). Palmarès évalués 2026-08-18 : Spotify podcastcharts → API morte (HTTP 500) ; ADISQ → SPA JavaScript + artistes sans liens sociaux ; Bible urbaine → articles en prose sans handles (liaison ambiguë interdite §12) → Apple retenu. Fusion naturelle avec balados-itunes (même clé podcast:collectionId)" | |
| 60 | + }, | |
| 43 | 61 | { |
| 44 | 62 | "id": "balados-itunes", |
| 45 | 63 | "famille": "decouverte", |
@@ -71,19 +89,19 @@ | ||
| 71 | 89 | "id": "youtube", |
| 72 | 90 | "famille": "enrichissement", |
| 73 | 91 | "palier": 2, |
| 74 | − "acces": "YouTube Data API v3 (officielle) si YOUTUBE_API_KEY, sinon page publique /@handle", | |
| 75 | − "signal_identite": "api_officielle (0.95) quand confirmé par l'API", | |
| 76 | − "cadence": "hebdomadaire", | |
| 77 | − "notes": "abonnés par chaîne" | |
| 92 | + "acces": "YouTube Data API v3 (officielle) si YOUTUBE_API_KEY, sinon voie publique complète SANS clé (vague 3) : page de chaîne (ytInitialData) + InnerTube browse du panneau « À propos » (clé publique embarquée) + page /videos", | |
| 93 | + "signal_identite": "api_officielle (0.95) quand confirmé par l'API ; la voie publique ne touche pas au score", | |
| 94 | + "cadence": "quotidienne (watch) — cap 300 chaînes/passage, rotation 14 j (≈4 200 comptes → convergence ~2 semaines)", | |
| 95 | + "notes": "abonnés, nb de vidéos, vues totales (si affichées), date de la dernière vidéo (dormant >12 mois), description → bio, bannière + avatar, pays affiché" | |
| 78 | 96 | }, |
| 79 | 97 | { |
| 80 | 98 | "id": "twitch", |
| 81 | 99 | "famille": "enrichissement", |
| 82 | 100 | "palier": 2, |
| 83 | − "acces": "API officielle Helix (client_credentials) — sauté sans clés", | |
| 84 | − "signal_identite": "api_officielle (0.95)", | |
| 101 | + "acces": "API GQL publique du site web officiel (gql.twitch.tv, Client-ID public du client web — SANS clé, requêtes groupées 20 profils/POST) ; voie Helix officielle reprise si TWITCH_CLIENT_ID/SECRET fournis", | |
| 102 | + "signal_identite": "api_officielle (0.95) via Helix ; la voie GQL publique ne touche pas au score", | |
| 85 | 103 | "cadence": "hebdomadaire", |
| 86 | − "notes": "existence, type de diffuseur, description" | |
| 104 | + "notes": "vague 3 — abonnés (followers.totalCount, que Helix ne donne plus sans jeton de modérateur depuis 2023), description → bio, avatar, partenaire/affilié, dernière diffusion + jeu (fraîcheur). Empreinte TLS : requests accepté (vérifié 2026-08-18), repli curl-binaire prévu" | |
| 87 | 105 | }, |
| 88 | 106 | { |
| 89 | 107 | "id": "link-in-bio", |
modified
docs/connecteurs/INDEX.md
+15 −13
@@ -1,22 +1,24 @@ | ||
| 1 | 1 | # Créa-Ka — Index des connecteurs |
| 2 | 2 | |
| 3 | −_Généré automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 03:30 — ne pas éditer à la main, régénérer._ | |
| 3 | +_Généré automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 06:25 — ne pas éditer à la main, régénérer._ | |
| 4 | 4 | |
| 5 | −**14 sources au registre** · **5664 créateurs** et **6527 comptes** en BD. | |
| 5 | +**16 sources au registre** · **5740 créateurs** et **6714 comptes** en BD. | |
| 6 | 6 | |
| 7 | 7 | | Source | Famille | Palier | Type d'accès | Backend | Découverts | Comptes (plateforme) | État | Dernier passage | |
| 8 | 8 | |---|---|---|---|---|---|---|---|---| |
| 9 | 9 | | [`listes-medias`](listes-medias.md) | decouverte | P3 | dataset local compilé (data/seed) | direct | 310 | — | actif | 2026-08-18T06:23… | |
| 10 | 10 | | [`wikidata-qc`](wikidata-qc.md) | decouverte | P3 | API SPARQL publique (Wikidata) | direct | 73 | — | actif | 2026-08-18T06:27… | |
| 11 | 11 | | [`onlyqueb`](onlyqueb.md) | decouverte | P3 | sitemap XML + JSON-LD des pages profil (S… | direct | 253 | 248 (onlyfans) | actif | 2026-08-18T06:27… | |
| 12 | −| [`youtube-recherche`](youtube-recherche.md) | decouverte | P3 | API JSON | direct | 4595 | 4768 (youtube) | actif | 2026-08-18T07:22… | | |
| 13 | −| [`balados-itunes`](balados-itunes.md) | decouverte | P2 | API iTunes Search/Lookup (publique) | direct | 433 | 434 (podcast) | actif | 2026-08-18T06:23… | | |
| 14 | −| [`instagram-profil`](instagram-profil.md) | enrichissement | P4 | pages HTML publiques | Scrapfly | 0 | 460 (instagram) | actif | 2026-08-17T23:23… | | |
| 15 | −| [`tiktok-profil`](tiktok-profil.md) | enrichissement | P4 | pages HTML publiques | Scrapfly | 0 | 197 (tiktok) | actif | 2026-08-18T00:31… | | |
| 16 | −| [`youtube`](youtube.md) | enrichissement | P2 | API JSON | direct | 0 | 4768 (youtube) | actif | 2026-08-18T01:06… | | |
| 17 | −| [`twitch`](twitch.md) | enrichissement | P2 | API JSON | direct | 0 | 26 (twitch) | actif | 2026-08-18T01:06… | | |
| 18 | −| [`link-in-bio`](link-in-bio.md) | enrichissement | P1 | pages HTML publiques | Firecrawl | 0 | — | actif | 2026-08-18T06:20… | | |
| 19 | −| [`balados-rss`](balados-rss.md) | enrichissement | P1 | flux RSS publics | direct | 0 | 434 (podcast) | actif | 2026-08-18T06:17… | | |
| 20 | −| [`podcastindex`](podcastindex.md) | enrichissement | P2 | API JSON | direct | 0 | 434 (podcast) | prêt | — | | |
| 21 | −| [`x-profil`](x-profil.md) | enrichissement | P3 | pages HTML publiques | direct | 0 | 186 (x) | actif | 2026-08-18T06:15… | | |
| 22 | −| [`bio-liens`](bio-liens.md) | enrichissement | P1 | pages HTML publiques | direct | 0 | — | actif | 2026-08-18T06:17… | | |
| 12 | +| [`youtube-recherche`](youtube-recherche.md) | decouverte | P3 | API JSON | direct | 4595 | 4784 (youtube) | actif | 2026-08-18T07:22… | | |
| 13 | +| [`wikipedia-qc`](wikipedia-qc.md) | decouverte | P3 | API SPARQL publique (Wikidata) | direct | 76 | — | actif | 2026-08-18T10:07… | | |
| 14 | +| [`palmares-apple`](palmares-apple.md) | decouverte | P2 | API iTunes Search/Lookup (publique) | direct | 0 | 435 (podcast) | actif | 2026-08-18T09:58… | | |
| 15 | +| [`balados-itunes`](balados-itunes.md) | decouverte | P2 | API iTunes Search/Lookup (publique) | direct | 433 | 435 (podcast) | actif | 2026-08-18T06:23… | | |
| 16 | +| [`instagram-profil`](instagram-profil.md) | enrichissement | P4 | pages HTML publiques | Scrapfly | 0 | 476 (instagram) | actif | 2026-08-18T07:51… | | |
| 17 | +| [`tiktok-profil`](tiktok-profil.md) | enrichissement | P4 | pages HTML publiques | Scrapfly | 0 | 199 (tiktok) | actif | 2026-08-18T09:16… | | |
| 18 | +| [`youtube`](youtube.md) | enrichissement | P2 | API JSON | direct | 0 | 4784 (youtube) | actif | 2026-08-18T10:24… | | |
| 19 | +| [`twitch`](twitch.md) | enrichissement | P2 | API JSON | direct | 0 | 28 (twitch) | actif | 2026-08-18T09:50… | | |
| 20 | +| [`link-in-bio`](link-in-bio.md) | enrichissement | P1 | pages HTML publiques | Firecrawl | 0 | — | actif | 2026-08-18T09:57… | | |
| 21 | +| [`balados-rss`](balados-rss.md) | enrichissement | P1 | flux RSS publics | direct | 0 | 435 (podcast) | actif | 2026-08-18T09:55… | | |
| 22 | +| [`podcastindex`](podcastindex.md) | enrichissement | P2 | API JSON | direct | 0 | 435 (podcast) | prêt | — | | |
| 23 | +| [`x-profil`](x-profil.md) | enrichissement | P3 | pages HTML publiques | direct | 0 | 209 (x) | actif | 2026-08-18T09:55… | | |
| 24 | +| [`bio-liens`](bio-liens.md) | enrichissement | P1 | pages HTML publiques | direct | 0 | — | actif | 2026-08-18T09:57… | | |
modified
docs/connecteurs/balados-itunes.md
+11 −11
@@ -1,6 +1,6 @@ | ||
| 1 | 1 | # `balados-itunes` — connecteur decouverte (palier 2) |
| 2 | 2 | |
| 3 | −_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 03:30 — ne pas éditer à la main, régénérer._ | |
| 3 | +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 06:25 — ne pas éditer à la main, régénérer._ | |
| 4 | 4 | |
| 5 | 5 | **État : actif** · Famille : decouverte (discovery) · Backend : direct · Créateurs découverts : 433 · touchés : 433 |
| 6 | 6 | |
@@ -29,20 +29,20 @@ Source de **découverte** : produit des fiches `Creator` (tables `creators` + `a | ||
| 29 | 29 | |
| 30 | 30 | | Champ du doc créateur | Contenu | Renseigné | Exemple réel | |
| 31 | 31 | |---|---|---|---| |
| 32 | −| `display_name` | Nom public | 100 % | Relève et Repreneuriat au Québec - Le Podcast | | |
| 33 | −| `bio` | Bio | 97 % | Le balado qui met l’humain au cœur des transferts d’entreprise. Dans les années à venir, … | | |
| 32 | +| `display_name` | Nom public | 100 % | Québec Nostalgie : le podcast | | |
| 33 | +| `bio` | Bio | 97 % | Fidèle à la page Instagram du même nom, Québec Nostalgie : le podcast est un véritable vo… | | |
| 34 | 34 | | `region` | Région | 0 % | — | |
| 35 | 35 | | `city` | Ville | 0 % | — | |
| 36 | −| `niches` | Niches (liste) | 100 % | actualite-opinion, arts, finance-affaires | | |
| 36 | +| `niches` | Niches (liste) | 100 % | actualite-opinion | | |
| 37 | 37 | | `languages` | Langues (liste) | 100 % | fr | |
| 38 | 38 | | `creator_type` | Type de créateur | 100 % | podcasteur | |
| 39 | −| `primary_platform` | Plateforme principale | 100 % | youtube | | |
| 40 | −| `platforms` | Comptes sociaux (liste) | 100 % | youtube:@releveetrepreneuriat, podcast:@1889244567 | | |
| 41 | −| `avatar_url` | Avatar | 100 % | https://is1-ssl.mzstatic.com/image/thumb/Podcasts211/v4/66/d3/96/66d3962a-1201-b271-25bb-… | | |
| 42 | −| `total_reach` | Portée totale (abonnés cumulés) | 0 % | 176 | | |
| 43 | −| `link_in_bio_url` | Lien-en-bio | 0 % | — | | |
| 39 | +| `primary_platform` | Plateforme principale | 100 % | instagram | | |
| 40 | +| `platforms` | Comptes sociaux (liste) | 100 % | instagram:@quebec_nostalgie, youtube:@UC10F7nTroeQocT2vqf2LoIw, podcast:@1741442896 | | |
| 41 | +| `avatar_url` | Avatar | 100 % | https://is1-ssl.mzstatic.com/image/thumb/Podcasts211/v4/3a/5f/12/3a5f1256-74a6-f716-ffb8-… | | |
| 42 | +| `total_reach` | Portée totale (abonnés cumulés) | 0 % | 109744 | | |
| 43 | +| `link_in_bio_url` | Lien-en-bio | 0 % | https://linktr.ee/QuebecNostalgie | | |
| 44 | 44 | |
| 45 | −Cible d'**enrichissement** : colonnes de la table `accounts` pour la/les plateforme(s) `podcast` (434 comptes en BD, confiance moyenne 0.98). | |
| 45 | +Cible d'**enrichissement** : colonnes de la table `accounts` pour la/les plateforme(s) `podcast` (435 comptes en BD, confiance moyenne 0.98). | |
| 46 | 46 | |
| 47 | 47 | | Colonne `accounts` | Contenu | Renseignée | Exemple réel | |
| 48 | 48 | |---|---|---|---| |
@@ -66,7 +66,7 @@ Cible d'**enrichissement** : colonnes de la table `accounts` pour la/les platefo | ||
| 66 | 66 | |
| 67 | 67 | - **Créateurs découverts par la source** (`doc.source`) : 433 · **fiches touchées** (`source_ids`) : 433 |
| 68 | 68 | - **Complétude clé (découverts)** : niches 100 % · avatar 100 % · portée 0 % · région 0 % |
| 69 | −- **Comptes `podcast` en BD** : 434 — abonnés 0 % · métriques 100 % · dernière vérification 2026-08-18T06:23:00Z | |
| 69 | +- **Comptes `podcast` en BD** : 435 — abonnés 0 % · métriques 100 % · dernière vérification 2026-08-18T09:56:23Z | |
| 70 | 70 | - **Runs journalisés (60 derniers)** : 7, dont 0 avec erreurs |
| 71 | 71 | |
| 72 | 72 | ## Erreurs connues & dépannage |
modified
docs/connecteurs/balados-rss.md
+7 −6
@@ -1,6 +1,6 @@ | ||
| 1 | 1 | # `balados-rss` — connecteur enrichissement (palier 1) |
| 2 | 2 | |
| 3 | −_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 03:30 — ne pas éditer à la main, régénérer._ | |
| 3 | +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 06:25 — ne pas éditer à la main, régénérer._ | |
| 4 | 4 | |
| 5 | 5 | **État : actif** · Famille : enrichissement (enrichment) · Backend : direct · Créateurs découverts : 0 · touchés : 0 |
| 6 | 6 | |
@@ -25,7 +25,7 @@ Connecteur ENRICHISSEMENT « balados-rss » — lecture directe des flux RSS aut | ||
| 25 | 25 | |
| 26 | 26 | ## Champs récupérés → schéma cible |
| 27 | 27 | |
| 28 | −Cible d'**enrichissement** : colonnes de la table `accounts` pour la/les plateforme(s) `podcast` (434 comptes en BD, confiance moyenne 0.98). | |
| 28 | +Cible d'**enrichissement** : colonnes de la table `accounts` pour la/les plateforme(s) `podcast` (435 comptes en BD, confiance moyenne 0.98). | |
| 29 | 29 | |
| 30 | 30 | | Colonne `accounts` | Contenu | Renseignée | Exemple réel | |
| 31 | 31 | |---|---|---|---| |
@@ -41,20 +41,21 @@ Cible d'**enrichissement** : colonnes de la table `accounts` pour la/les platefo | ||
| 41 | 41 | ## Fréquence & budget |
| 42 | 42 | |
| 43 | 43 | - **Cadence déclarée (registre)** : quotidienne (watch) — rotation complète des 433 balados en ~3 jours, puis hebdomadaire |
| 44 | −- **Cadence observée** (médiane sync_log) : ≈ 17 min | |
| 45 | −- **Dernier passage** : 2026-08-18T06:17:19Z — 8 créateurs, 36 comptes, +0 / ~8, 5 erreur(s), 12 s | |
| 44 | +- **Cadence observée** (médiane sync_log) : ≈ 2.0 h | |
| 45 | +- **Dernier passage** : 2026-08-18T09:55:43Z — 8 créateurs, 36 comptes, +0 / ~8, 5 erreur(s), 12 s | |
| 46 | 46 | - **Throttling** : 0.2 s entre requêtes (`request_delay`) |
| 47 | 47 | |
| 48 | 48 | ## Volumétrie & complétude |
| 49 | 49 | |
| 50 | 50 | - **Créateurs découverts par la source** (`doc.source`) : 0 · **fiches touchées** (`source_ids`) : 0 |
| 51 | −- **Comptes `podcast` en BD** : 434 — abonnés 0 % · métriques 100 % · dernière vérification 2026-08-18T06:23:00Z | |
| 52 | −- **Runs journalisés (60 derniers)** : 4, dont 3 avec erreurs | |
| 51 | +- **Comptes `podcast` en BD** : 435 — abonnés 0 % · métriques 100 % · dernière vérification 2026-08-18T09:56:23Z | |
| 52 | +- **Runs journalisés (60 derniers)** : 5, dont 4 avec erreurs | |
| 53 | 53 | |
| 54 | 54 | ## Erreurs connues & dépannage |
| 55 | 55 | |
| 56 | 56 | | Passage | Erreurs | Alerte (sync_log) | |
| 57 | 57 | |---|---|---| |
| 58 | +| 2026-08-18T09:55:43Z | 5 | — | | |
| 58 | 59 | | 2026-08-18T06:17:19Z | 5 | — | |
| 59 | 60 | | 2026-08-18T06:17:07Z | 5 | — | |
| 60 | 61 | | 2026-08-18T06:16:42Z | 5 | — | |
modified
docs/connecteurs/bio-liens.md
+3 −3
@@ -1,6 +1,6 @@ | ||
| 1 | 1 | # `bio-liens` — connecteur enrichissement (palier 1) |
| 2 | 2 | |
| 3 | −_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 03:30 — ne pas éditer à la main, régénérer._ | |
| 3 | +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 06:25 — ne pas éditer à la main, régénérer._ | |
| 4 | 4 | |
| 5 | 5 | **État : actif** · Famille : enrichissement (enrichment) · Backend : direct · Créateurs découverts : 0 · touchés : 0 |
| 6 | 6 | |
@@ -30,14 +30,14 @@ Aucune donnée attribuable à cette source en BD pour l'instant (clés manquante | ||
| 30 | 30 | |
| 31 | 31 | - **Cadence déclarée (registre)** : quotidienne (watch), en dernier du pipeline |
| 32 | 32 | - **Cadence observée** (médiane sync_log) : — |
| 33 | −- **Dernier passage** : 2026-08-18T06:17:45Z — 26 créateurs, 63 comptes, +0 / ~26, 0 erreur(s), 0 s | |
| 33 | +- **Dernier passage** : 2026-08-18T09:57:40Z — 1 créateurs, 2 comptes, +0 / ~1, 0 erreur(s), 0 s | |
| 34 | 34 | - **Caps / budgets du module** : `MAX_NEW_PER_CREATOR` = 5 |
| 35 | 35 | - **Throttling** : 0.0 s entre requêtes (`request_delay`) |
| 36 | 36 | |
| 37 | 37 | ## Volumétrie & complétude |
| 38 | 38 | |
| 39 | 39 | - **Créateurs découverts par la source** (`doc.source`) : 0 · **fiches touchées** (`source_ids`) : 0 |
| 40 | −- **Runs journalisés (60 derniers)** : 1, dont 0 avec erreurs | |
| 40 | +- **Runs journalisés (60 derniers)** : 2, dont 0 avec erreurs | |
| 41 | 41 | |
| 42 | 42 | ## Erreurs connues & dépannage |
| 43 | 43 | |
modified
docs/connecteurs/instagram-profil.md
+10 −10
@@ -1,6 +1,6 @@ | ||
| 1 | 1 | # `instagram-profil` — connecteur enrichissement (palier 4) |
| 2 | 2 | |
| 3 | −_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 03:30 — ne pas éditer à la main, régénérer._ | |
| 3 | +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 06:25 — ne pas éditer à la main, régénérer._ | |
| 4 | 4 | |
| 5 | 5 | **État : actif** · Famille : enrichissement (enrichment) · Backend : Scrapfly · Créateurs découverts : 0 · touchés : 0 |
| 6 | 6 | |
@@ -25,31 +25,31 @@ Connecteur ENRICHISSEMENT Instagram — profil public par créateur : abonnés, | ||
| 25 | 25 | |
| 26 | 26 | ## Champs récupérés → schéma cible |
| 27 | 27 | |
| 28 | −Cible d'**enrichissement** : colonnes de la table `accounts` pour la/les plateforme(s) `instagram` (460 comptes en BD, confiance moyenne 0.88). | |
| 28 | +Cible d'**enrichissement** : colonnes de la table `accounts` pour la/les plateforme(s) `instagram` (476 comptes en BD, confiance moyenne 0.88). | |
| 29 | 29 | |
| 30 | 30 | | Colonne `accounts` | Contenu | Renseignée | Exemple réel | |
| 31 | 31 | |---|---|---|---| |
| 32 | 32 | | `handle` | Handle | 100 % | catherinepaiz | |
| 33 | 33 | | `url` | URL du profil | 100 % | https://www.instagram.com/catherinepaiz/ | |
| 34 | −| `followers` | Abonnés | 72 % | 7023891 | | |
| 35 | −| `verified` | Badge vérifié | 59 % | 1 | | |
| 34 | +| `followers` | Abonnés | 70 % | 7023547 | | |
| 35 | +| `verified` | Badge vérifié | 58 % | 1 | | |
| 36 | 36 | | `confidence` | Confiance d'identité | 100 % | 0.85 | |
| 37 | 37 | | `signal` | Signal d'identité | 100 % | base_publique | |
| 38 | −| `metrics` | Métriques détaillées (JSON) | 59 % | {"following": 248, "posts": 667, "is_business": false} | | |
| 39 | −| `last_checked` | Dernière vérification | 100 % | 2026-08-18T06:27:51Z | | |
| 38 | +| `metrics` | Métriques détaillées (JSON) | 58 % | {"following": 248, "posts": 667, "is_business": false} | | |
| 39 | +| `last_checked` | Dernière vérification | 100 % | 2026-08-18T07:23:10Z | | |
| 40 | 40 | |
| 41 | 41 | ## Fréquence & budget |
| 42 | 42 | |
| 43 | 43 | - **Cadence déclarée (registre)** : hebdomadaire (gros créateurs) / mensuelle |
| 44 | −- **Cadence observée** (médiane sync_log) : ≈ 87 min | |
| 45 | −- **Dernier passage** : 2026-08-17T23:23:45Z — 271 créateurs, 542 comptes, +0 / ~271, 0 erreur(s), 0 s | |
| 44 | +- **Cadence observée** (médiane sync_log) : ≈ 2.1 h | |
| 45 | +- **Dernier passage** : 2026-08-18T07:51:39Z — 274 créateurs, 698 comptes, +0 / ~274, 0 erreur(s), 1724 s | |
| 46 | 46 | - **Throttling** : 1.5 s entre requêtes (`request_delay`) |
| 47 | 47 | |
| 48 | 48 | ## Volumétrie & complétude |
| 49 | 49 | |
| 50 | 50 | - **Créateurs découverts par la source** (`doc.source`) : 0 · **fiches touchées** (`source_ids`) : 0 |
| 51 | −- **Comptes `instagram` en BD** : 460 — abonnés 72 % · métriques 59 % · dernière vérification 2026-08-18T06:27:51Z | |
| 52 | −- **Runs journalisés (60 derniers)** : 3, dont 0 avec erreurs | |
| 51 | +- **Comptes `instagram` en BD** : 476 — abonnés 70 % · métriques 58 % · dernière vérification 2026-08-18T10:07:33Z | |
| 52 | +- **Runs journalisés (60 derniers)** : 4, dont 0 avec erreurs | |
| 53 | 53 | |
| 54 | 54 | ## Erreurs connues & dépannage |
| 55 | 55 | |
modified
docs/connecteurs/link-in-bio.md
+4 −4
@@ -1,6 +1,6 @@ | ||
| 1 | 1 | # `link-in-bio` — connecteur enrichissement (palier 1) |
| 2 | 2 | |
| 3 | −_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 03:30 — ne pas éditer à la main, régénérer._ | |
| 3 | +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 06:25 — ne pas éditer à la main, régénérer._ | |
| 4 | 4 | |
| 5 | 5 | **État : actif** · Famille : enrichissement (enrichment) · Backend : Firecrawl · Créateurs découverts : 0 · touchés : 0 |
| 6 | 6 | |
@@ -29,14 +29,14 @@ Aucune donnée attribuable à cette source en BD pour l'instant (clés manquante | ||
| 29 | 29 | ## Fréquence & budget |
| 30 | 30 | |
| 31 | 31 | - **Cadence déclarée (registre)** : mensuelle |
| 32 | −- **Cadence observée** (médiane sync_log) : — | |
| 33 | −- **Dernier passage** : 2026-08-18T06:20:19Z — 74 créateurs, 288 comptes, +0 / ~74, 0 erreur(s), 114 s | |
| 32 | +- **Cadence observée** (médiane sync_log) : ≈ 4.4 h | |
| 33 | +- **Dernier passage** : 2026-08-18T09:57:40Z — 78 créateurs, 302 comptes, +0 / ~78, 0 erreur(s), 116 s | |
| 34 | 34 | - **Throttling** : 1.2 s entre requêtes (`request_delay`) |
| 35 | 35 | |
| 36 | 36 | ## Volumétrie & complétude |
| 37 | 37 | |
| 38 | 38 | - **Créateurs découverts par la source** (`doc.source`) : 0 · **fiches touchées** (`source_ids`) : 0 |
| 39 | −- **Runs journalisés (60 derniers)** : 2, dont 0 avec erreurs | |
| 39 | +- **Runs journalisés (60 derniers)** : 3, dont 0 avec erreurs | |
| 40 | 40 | |
| 41 | 41 | ## Erreurs connues & dépannage |
| 42 | 42 | |
modified
docs/connecteurs/listes-medias.md
+6 −6
@@ -1,6 +1,6 @@ | ||
| 1 | 1 | # `listes-medias` — connecteur decouverte (palier 3) |
| 2 | 2 | |
| 3 | −_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 03:30 — ne pas éditer à la main, régénérer._ | |
| 3 | +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 06:25 — ne pas éditer à la main, régénérer._ | |
| 4 | 4 | |
| 5 | 5 | **État : actif** · Famille : decouverte (discovery) · Backend : direct · Créateurs découverts : 310 · touchés : 310 |
| 6 | 6 | |
@@ -29,7 +29,7 @@ Source de **découverte** : produit des fiches `Creator` (tables `creators` + `a | ||
| 29 | 29 | | Champ du doc créateur | Contenu | Renseigné | Exemple réel | |
| 30 | 30 | |---|---|---|---| |
| 31 | 31 | | `display_name` | Nom public | 100 % | Enola Bédard | |
| 32 | −| `bio` | Bio | 66 % | 📍Los Angeles 📩: [retiré] Tik Tok 17M Youtube 5M | | |
| 32 | +| `bio` | Bio | 72 % | Énola Bédard (born September 14, 2000) is a Canadian dancer, singer, and social media per… | | |
| 33 | 33 | | `region` | Région | 49 % | — | |
| 34 | 34 | | `city` | Ville | 49 % | — | |
| 35 | 35 | | `niches` | Niches (liste) | 100 % | danse, lifestyle | |
@@ -37,9 +37,9 @@ Source de **découverte** : produit des fiches `Creator` (tables `creators` + `a | ||
| 37 | 37 | | `creator_type` | Type de créateur | 100 % | createur-tiktok | |
| 38 | 38 | | `primary_platform` | Plateforme principale | 100 % | tiktok | |
| 39 | 39 | | `platforms` | Comptes sociaux (liste) | 100 % | tiktok:@enola.bedard, youtube:@enola.bedard, instagram:@enola.bedard | |
| 40 | −| `avatar_url` | Avatar | 68 % | https://scontent-lga3-2.cdninstagram.com/v/t51.82787-19/515154150_18510310135012441_32506… | | |
| 41 | −| `total_reach` | Portée totale (abonnés cumulés) | 90 % | 24500000 | | |
| 42 | −| `link_in_bio_url` | Lien-en-bio | 23 % | — | | |
| 40 | +| `avatar_url` | Avatar | 74 % | https://scontent-lga3-2.cdninstagram.com/v/t51.82787-19/515154150_18510310135012441_32506… | | |
| 41 | +| `total_reach` | Portée totale (abonnés cumulés) | 90 % | 25307536 | | |
| 42 | +| `link_in_bio_url` | Lien-en-bio | 24 % | — | | |
| 43 | 43 | |
| 44 | 44 | ## Fréquence & budget |
| 45 | 45 | |
@@ -51,7 +51,7 @@ Source de **découverte** : produit des fiches `Creator` (tables `creators` + `a | ||
| 51 | 51 | ## Volumétrie & complétude |
| 52 | 52 | |
| 53 | 53 | - **Créateurs découverts par la source** (`doc.source`) : 310 · **fiches touchées** (`source_ids`) : 310 |
| 54 | −- **Complétude clé (découverts)** : niches 100 % · avatar 68 % · portée 90 % · région 49 % | |
| 54 | +- **Complétude clé (découverts)** : niches 100 % · avatar 74 % · portée 90 % · région 49 % | |
| 55 | 55 | - **Runs journalisés (60 derniers)** : 11, dont 0 avec erreurs |
| 56 | 56 | |
| 57 | 57 | ## Erreurs connues & dépannage |
modified
docs/connecteurs/onlyqueb.md
+4 −4
@@ -1,6 +1,6 @@ | ||
| 1 | 1 | # `onlyqueb` — connecteur decouverte (palier 3) |
| 2 | 2 | |
| 3 | −_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 03:30 — ne pas éditer à la main, régénérer._ | |
| 3 | +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 06:25 — ne pas éditer à la main, régénérer._ | |
| 4 | 4 | |
| 5 | 5 | **État : actif** · Famille : decouverte (discovery) · Backend : direct · Créateurs découverts : 253 · touchés : 253 |
| 6 | 6 | |
@@ -39,7 +39,7 @@ Source de **découverte** : produit des fiches `Creator` (tables `creators` + `a | ||
| 39 | 39 | | `primary_platform` | Plateforme principale | 100 % | instagram | |
| 40 | 40 | | `platforms` | Comptes sociaux (liste) | 100 % | instagram:@jadelavoie, x:@missjadelavoie, onlyfans:@misslavoie | |
| 41 | 41 | | `avatar_url` | Avatar | 100 % | https://storage.googleapis.com/onlyqueb/MQZBiXsbMQa31zxj9P_mabNxNITFBxa6 | |
| 42 | −| `total_reach` | Portée totale (abonnés cumulés) | 16 % | 1571113 | | |
| 42 | +| `total_reach` | Portée totale (abonnés cumulés) | 18 % | 1571106 | | |
| 43 | 43 | | `link_in_bio_url` | Lien-en-bio | 2 % | — | |
| 44 | 44 | |
| 45 | 45 | Cible d'**enrichissement** : colonnes de la table `accounts` pour la/les plateforme(s) `onlyfans` (248 comptes en BD, confiance moyenne 0.90). |
@@ -65,8 +65,8 @@ Cible d'**enrichissement** : colonnes de la table `accounts` pour la/les platefo | ||
| 65 | 65 | ## Volumétrie & complétude |
| 66 | 66 | |
| 67 | 67 | - **Créateurs découverts par la source** (`doc.source`) : 253 · **fiches touchées** (`source_ids`) : 253 |
| 68 | −- **Complétude clé (découverts)** : niches 100 % · avatar 100 % · portée 16 % · région 0 % | |
| 69 | −- **Comptes `onlyfans` en BD** : 248 — abonnés 0 % · métriques 0 % · dernière vérification 2026-08-18T06:27:32Z | |
| 68 | +- **Complétude clé (découverts)** : niches 100 % · avatar 100 % · portée 18 % · région 0 % | |
| 69 | +- **Comptes `onlyfans` en BD** : 248 — abonnés 0 % · métriques 0 % · dernière vérification 2026-08-18T09:57:40Z | |
| 70 | 70 | - **Runs journalisés (60 derniers)** : 7, dont 0 avec erreurs |
| 71 | 71 | |
| 72 | 72 | ## Erreurs connues & dépannage |
added
docs/connecteurs/palmares-apple.md
+72 −0
@@ -0,0 +1,72 @@ | ||
| 1 | +# `palmares-apple` — connecteur decouverte (palier 2) | |
| 2 | + | |
| 3 | +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 06:25 — ne pas éditer à la main, régénérer._ | |
| 4 | + | |
| 5 | +**État : actif** · Famille : decouverte (discovery) · Backend : direct · Créateurs découverts : 0 · touchés : 0 | |
| 6 | + | |
| 7 | +## Description de la source | |
| 8 | + | |
| 9 | +Connecteur DÉCOUVERTE — balados québécois dans le TOP 100 canadien d'Apple Podcasts. Mode d'accès : API marketing officielle d'Apple (rss.marketingtools.apple.com, gratuite, sans clé) + lookup iTunes groupé + tête de flux RSS pour l'attribution QC. Palier 2 (§9). V3. | |
| 10 | + | |
| 11 | +- **Notes (registre)** : vague 3 — balados QC qui percent dans le top 100 CA (metrics.chart_rank_ca). Palmarès évalués 2026-08-18 : Spotify podcastcharts → API morte (HTTP 500) ; ADISQ → SPA JavaScript + artistes sans liens sociaux ; Bible urbaine → articles en prose sans handles (liaison ambiguë interdite §12) → Apple retenu. Fusion naturelle avec balados-itunes (même clé podcast:collectionId) | |
| 12 | +- **Signal d'identité** : profil_source (0.98) (colonne `accounts.signal` / `confidence`) | |
| 13 | +- **Module** : `creaka/connectors/palmares_apple.py` — classe `PalmaresAppleConnector` (`kind = "discovery"`) | |
| 14 | + | |
| 15 | +## Accès | |
| 16 | + | |
| 17 | +- **Accès (registre)** : API marketing OFFICIELLE d'Apple (rss.marketingtools.apple.com, top 100 podcasts Canada, sans clé) + lookup iTunes groupé + tête de flux RSS pour l'attribution QC | |
| 18 | +- **Type d'accès (code)** : API iTunes Search/Lookup (publique) | |
| 19 | +- **Endpoint de base** : https://itunes.apple.com/lookup | |
| 20 | +- **URLs du module** : https://rss.marketingtools.apple.com/api/v2/ca/podcasts/ · https://itunes.apple.com/lookup | |
| 21 | +- **Pagination** : réponse unique (pas de pagination) | |
| 22 | +- **Backend anti-bot / rendu** : requests direct (session UA CreaKaBot, throttling poli) | |
| 23 | +- **Politesse** : 1.0 s entre requêtes, timeout 30 s, UA `CreaKaBot/1.0 (+https://www.crea-ka.com/bot)` | |
| 24 | +- **Authentification** : clé(s) API requise(s) — voir statut/registre | |
| 25 | + | |
| 26 | +## Champs récupérés → schéma cible | |
| 27 | + | |
| 28 | +Cible d'**enrichissement** : colonnes de la table `accounts` pour la/les plateforme(s) `podcast` (435 comptes en BD, confiance moyenne 0.98). | |
| 29 | + | |
| 30 | +| Colonne `accounts` | Contenu | Renseignée | Exemple réel | | |
| 31 | +|---|---|---|---| | |
| 32 | +| `handle` | Handle | 100 % | — | | |
| 33 | +| `url` | URL du profil | 100 % | — | | |
| 34 | +| `followers` | Abonnés | 0 % | — | | |
| 35 | +| `verified` | Badge vérifié | 0 % | — | | |
| 36 | +| `confidence` | Confiance d'identité | 100 % | — | | |
| 37 | +| `signal` | Signal d'identité | 100 % | — | | |
| 38 | +| `metrics` | Métriques détaillées (JSON) | 100 % | — | | |
| 39 | +| `last_checked` | Dernière vérification | 100 % | — | | |
| 40 | + | |
| 41 | +## Fréquence & budget | |
| 42 | + | |
| 43 | +- **Cadence déclarée (registre)** : quotidienne (watch) — le palmarès bouge chaque jour | |
| 44 | +- **Cadence observée** (médiane sync_log) : — | |
| 45 | +- **Dernier passage** : 2026-08-18T09:58:54Z — 0 créateurs, 0 comptes, +0 / ~0, 0 erreur(s), 211 s | |
| 46 | +- **Throttling** : 1.0 s entre requêtes (`request_delay`) | |
| 47 | + | |
| 48 | +## Volumétrie & complétude | |
| 49 | + | |
| 50 | +- **Créateurs découverts par la source** (`doc.source`) : 0 · **fiches touchées** (`source_ids`) : 0 | |
| 51 | +- **Comptes `podcast` en BD** : 435 — abonnés 0 % · métriques 100 % · dernière vérification 2026-08-18T09:56:23Z | |
| 52 | +- **Runs journalisés (60 derniers)** : 2, dont 0 avec erreurs | |
| 53 | + | |
| 54 | +## Erreurs connues & dépannage | |
| 55 | + | |
| 56 | +| Passage | Erreurs | Alerte (sync_log) | | |
| 57 | +|---|---|---| | |
| 58 | +| 2026-08-18T09:58:54Z | 0 | 0 créateur retourné — source possiblement bloquée | | |
| 59 | + | |
| 60 | +Rejouer la source seule : `python3 run.py sync palmares-apple` · vérifier `sync_log` (`SELECT * FROM sync_log WHERE source='palmares-apple' ORDER BY ts DESC LIMIT 5;`). | |
| 61 | + | |
| 62 | +## Licence, attribution & conditions | |
| 63 | + | |
| 64 | +- **Cadre d'accès (registre)** : API marketing OFFICIELLE d'Apple (rss.marketingtools.apple.com, top 100 podcasts Canada, sans clé) + lookup iTunes groupé + tête de flux RSS pour l'attribution QC | |
| 65 | +- **API publique** utilisée selon ses conditions (pas de clé détournée, throttling poli) ; données limitées aux profils publics. | |
| 66 | +- Retrait sur demande : contact@spboucher.ai (opt-out honoré à la prochaine ingestion). | |
| 67 | + | |
| 68 | +## Historique | |
| 69 | + | |
| 70 | +- 2026-08-18 — date mentionnée au registre (voir notes/statut). | |
| 71 | +- 2026-08-18 — plus ancien passage journalisé dans `sync_log` (fenêtre des 60 derniers). | |
| 72 | +- 2026-08-18 — vague d'enrichissement : standardisation de la documentation des connecteurs (fiche générée par `scripts/gen_connector_docs.py`). | |
modified
docs/connecteurs/podcastindex.md
+3 −3
@@ -1,6 +1,6 @@ | ||
| 1 | 1 | # `podcastindex` — connecteur enrichissement (palier 2) |
| 2 | 2 | |
| 3 | −_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 03:30 — ne pas éditer à la main, régénérer._ | |
| 3 | +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 06:25 — ne pas éditer à la main, régénérer._ | |
| 4 | 4 | |
| 5 | 5 | **État : prêt — clés requises** · Famille : enrichissement (enrichment) · Backend : direct · Créateurs découverts : 0 · touchés : 0 |
| 6 | 6 | |
@@ -25,7 +25,7 @@ Connecteur ENRICHISSEMENT « podcastindex » — API Podcast Index (OFFICIELLE, | ||
| 25 | 25 | |
| 26 | 26 | ## Champs récupérés → schéma cible |
| 27 | 27 | |
| 28 | −Cible d'**enrichissement** : colonnes de la table `accounts` pour la/les plateforme(s) `podcast` (434 comptes en BD, confiance moyenne 0.98). | |
| 28 | +Cible d'**enrichissement** : colonnes de la table `accounts` pour la/les plateforme(s) `podcast` (435 comptes en BD, confiance moyenne 0.98). | |
| 29 | 29 | |
| 30 | 30 | | Colonne `accounts` | Contenu | Renseignée | Exemple réel | |
| 31 | 31 | |---|---|---|---| |
@@ -47,7 +47,7 @@ Cible d'**enrichissement** : colonnes de la table `accounts` pour la/les platefo | ||
| 47 | 47 | ## Volumétrie & complétude |
| 48 | 48 | |
| 49 | 49 | - **Créateurs découverts par la source** (`doc.source`) : 0 · **fiches touchées** (`source_ids`) : 0 |
| 50 | −- **Comptes `podcast` en BD** : 434 — abonnés 0 % · métriques 100 % · dernière vérification 2026-08-18T06:23:00Z | |
| 50 | +- **Comptes `podcast` en BD** : 435 — abonnés 0 % · métriques 100 % · dernière vérification 2026-08-18T09:56:23Z | |
| 51 | 51 | - **Runs journalisés (60 derniers)** : 0, dont 0 avec erreurs |
| 52 | 52 | |
| 53 | 53 | ## Erreurs connues & dépannage |
modified
docs/connecteurs/tiktok-profil.md
+9 −9
@@ -1,6 +1,6 @@ | ||
| 1 | 1 | # `tiktok-profil` — connecteur enrichissement (palier 4) |
| 2 | 2 | |
| 3 | −_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 03:30 — ne pas éditer à la main, régénérer._ | |
| 3 | +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 06:25 — ne pas éditer à la main, régénérer._ | |
| 4 | 4 | |
| 5 | 5 | **État : actif** · Famille : enrichissement (enrichment) · Backend : Scrapfly · Créateurs découverts : 0 · touchés : 0 |
| 6 | 6 | |
@@ -25,31 +25,31 @@ Connecteur ENRICHISSEMENT TikTok — profil public par créateur : abonnés, bad | ||
| 25 | 25 | |
| 26 | 26 | ## Champs récupérés → schéma cible |
| 27 | 27 | |
| 28 | −Cible d'**enrichissement** : colonnes de la table `accounts` pour la/les plateforme(s) `tiktok` (197 comptes en BD, confiance moyenne 0.90). | |
| 28 | +Cible d'**enrichissement** : colonnes de la table `accounts` pour la/les plateforme(s) `tiktok` (199 comptes en BD, confiance moyenne 0.90). | |
| 29 | 29 | |
| 30 | 30 | | Colonne `accounts` | Contenu | Renseignée | Exemple réel | |
| 31 | 31 | |---|---|---|---| |
| 32 | 32 | | `handle` | Handle | 100 % | enola.bedard | |
| 33 | 33 | | `url` | URL du profil | 100 % | https://www.tiktok.com/@enola.bedard | |
| 34 | −| `followers` | Abonnés | 53 % | 17000000 | | |
| 35 | −| `verified` | Badge vérifié | 35 % | 1 | | |
| 34 | +| `followers` | Abonnés | 65 % | 17100000 | | |
| 35 | +| `verified` | Badge vérifié | 55 % | 1 | | |
| 36 | 36 | | `confidence` | Confiance d'identité | 100 % | 0.85 | |
| 37 | 37 | | `signal` | Signal d'identité | 100 % | base_publique | |
| 38 | −| `metrics` | Métriques détaillées (JSON) | 35 % | {"following": 813, "likes": 438100000, "videos": 2847} | | |
| 39 | −| `last_checked` | Dernière vérification | 100 % | 2026-08-18T06:27:51Z | | |
| 38 | +| `metrics` | Métriques détaillées (JSON) | 55 % | {"following": 813, "likes": 438100000, "videos": 2847} | | |
| 39 | +| `last_checked` | Dernière vérification | 100 % | 2026-08-18T10:07:33Z | | |
| 40 | 40 | |
| 41 | 41 | ## Fréquence & budget |
| 42 | 42 | |
| 43 | 43 | - **Cadence déclarée (registre)** : hebdomadaire (gros créateurs) / mensuelle |
| 44 | 44 | - **Cadence observée** (médiane sync_log) : — |
| 45 | −- **Dernier passage** : 2026-08-18T00:31:47Z — 68 créateurs, 196 comptes, +0 / ~68, 0 erreur(s), 0 s | |
| 45 | +- **Dernier passage** : 2026-08-18T09:16:06Z — 76 créateurs, 257 comptes, +0 / ~76, 0 erreur(s), 5067 s | |
| 46 | 46 | - **Throttling** : 1.5 s entre requêtes (`request_delay`) |
| 47 | 47 | |
| 48 | 48 | ## Volumétrie & complétude |
| 49 | 49 | |
| 50 | 50 | - **Créateurs découverts par la source** (`doc.source`) : 0 · **fiches touchées** (`source_ids`) : 0 |
| 51 | −- **Comptes `tiktok` en BD** : 197 — abonnés 53 % · métriques 35 % · dernière vérification 2026-08-18T06:27:51Z | |
| 52 | −- **Runs journalisés (60 derniers)** : 1, dont 0 avec erreurs | |
| 51 | +- **Comptes `tiktok` en BD** : 199 — abonnés 65 % · métriques 55 % · dernière vérification 2026-08-18T10:07:33Z | |
| 52 | +- **Runs journalisés (60 derniers)** : 2, dont 0 avec erreurs | |
| 53 | 53 | |
| 54 | 54 | ## Erreurs connues & dépannage |
| 55 | 55 | |
modified
docs/connecteurs/twitch.md
+22 −21
@@ -1,55 +1,55 @@ | ||
| 1 | 1 | # `twitch` — connecteur enrichissement (palier 2) |
| 2 | 2 | |
| 3 | −_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 03:30 — ne pas éditer à la main, régénérer._ | |
| 3 | +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 06:25 — ne pas éditer à la main, régénérer._ | |
| 4 | 4 | |
| 5 | 5 | **État : actif** · Famille : enrichissement (enrichment) · Backend : direct · Créateurs découverts : 0 · touchés : 0 |
| 6 | 6 | |
| 7 | 7 | ## Description de la source |
| 8 | 8 | |
| 9 | −Connecteur ENRICHISSEMENT Twitch — existence + profil par créateur via l'API OFFICIELLE Helix (client_credentials). Palier 2 (§9, §10). Sans TWITCH_CLIENT_ID/TWITCH_CLIENT_SECRET : passage sauté proprement. | |
| 9 | +Connecteur ENRICHISSEMENT Twitch — profil public par créateur SANS clé via l'API GQL publique du site web officiel (Client-ID public), abonnés inclus. Voie Helix officielle conservée si des clés TWITCH_CLIENT_ID/TWITCH_CLIENT_SECRET apparaissent. Palier 2-3 (§9). | |
| 10 | 10 | |
| 11 | −- **Notes (registre)** : existence, type de diffuseur, description | |
| 12 | −- **Signal d'identité** : api_officielle (0.95) (colonne `accounts.signal` / `confidence`) | |
| 11 | +- **Notes (registre)** : vague 3 — abonnés (followers.totalCount, que Helix ne donne plus sans jeton de modérateur depuis 2023), description → bio, avatar, partenaire/affilié, dernière diffusion + jeu (fraîcheur). Empreinte TLS : requests accepté (vérifié 2026-08-18), repli curl-binaire prévu | |
| 12 | +- **Signal d'identité** : api_officielle (0.95) via Helix ; la voie GQL publique ne touche pas au score (colonne `accounts.signal` / `confidence`) | |
| 13 | 13 | - **Module** : `creaka/connectors/twitch.py` — classe `TwitchConnector` (`kind = "enrichment"`) |
| 14 | 14 | |
| 15 | 15 | ## Accès |
| 16 | 16 | |
| 17 | −- **Accès (registre)** : API officielle Helix (client_credentials) — sauté sans clés | |
| 17 | +- **Accès (registre)** : API GQL publique du site web officiel (gql.twitch.tv, Client-ID public du client web — SANS clé, requêtes groupées 20 profils/POST) ; voie Helix officielle reprise si TWITCH_CLIENT_ID/SECRET fournis | |
| 18 | 18 | - **Type d'accès (code)** : API JSON |
| 19 | 19 | - **Endpoint de base** : https://id.twitch.tv/oauth2/token |
| 20 | −- **URLs du module** : https://id.twitch.tv/oauth2/token · https://api.twitch.tv/helix/users | |
| 20 | +- **URLs du module** : https://id.twitch.tv/oauth2/token · https://api.twitch.tv/helix/users · https://gql.twitch.tv/gql | |
| 21 | 21 | - **Pagination** : réponse unique (pas de pagination) |
| 22 | 22 | - **Backend anti-bot / rendu** : requests direct (session UA CreaKaBot, throttling poli) |
| 23 | −- **Politesse** : 0.5 s entre requêtes, timeout 30 s, UA `CreaKaBot/1.0 (+https://www.crea-ka.com/bot)` | |
| 23 | +- **Politesse** : 1.0 s entre requêtes, timeout 30 s, UA `CreaKaBot/1.0 (+https://www.crea-ka.com/bot)` | |
| 24 | 24 | - **Authentification** : clé(s) API requise(s) — voir statut/registre |
| 25 | 25 | |
| 26 | 26 | ## Champs récupérés → schéma cible |
| 27 | 27 | |
| 28 | −Cible d'**enrichissement** : colonnes de la table `accounts` pour la/les plateforme(s) `twitch` (26 comptes en BD, confiance moyenne 0.89). | |
| 28 | +Cible d'**enrichissement** : colonnes de la table `accounts` pour la/les plateforme(s) `twitch` (28 comptes en BD, confiance moyenne 0.89). | |
| 29 | 29 | |
| 30 | 30 | | Colonne `accounts` | Contenu | Renseignée | Exemple réel | |
| 31 | 31 | |---|---|---|---| |
| 32 | −| `handle` | Handle | 100 % | heyraion | | |
| 33 | −| `url` | URL du profil | 100 % | https://www.twitch.tv/heyraion | | |
| 34 | −| `followers` | Abonnés | 15 % | 37900 | | |
| 35 | −| `verified` | Badge vérifié | 0 % | — | | |
| 32 | +| `handle` | Handle | 100 % | xqc | | |
| 33 | +| `url` | URL du profil | 100 % | https://www.twitch.tv/xqc | | |
| 34 | +| `followers` | Abonnés | 75 % | 12535964 | | |
| 35 | +| `verified` | Badge vérifié | 39 % | 1 | | |
| 36 | 36 | | `confidence` | Confiance d'identité | 100 % | 0.85 | |
| 37 | −| `signal` | Signal d'identité | 100 % | listes_medias | | |
| 38 | −| `metrics` | Métriques détaillées (JSON) | 0 % | — | | |
| 39 | −| `last_checked` | Dernière vérification | 100 % | 2026-08-18T06:23:00Z | | |
| 37 | +| `signal` | Signal d'identité | 100 % | base_publique | | |
| 38 | +| `metrics` | Métriques détaillées (JSON) | 68 % | {"last_stream": "2026-08-18T08:03:23.924661Z", "game": "Just Chatting"} | | |
| 39 | +| `last_checked` | Dernière vérification | 100 % | 2026-08-18T10:07:33Z | | |
| 40 | 40 | |
| 41 | 41 | ## Fréquence & budget |
| 42 | 42 | |
| 43 | 43 | - **Cadence déclarée (registre)** : hebdomadaire |
| 44 | 44 | - **Cadence observée** (médiane sync_log) : — |
| 45 | −- **Dernier passage** : 2026-08-18T01:06:19Z — 0 créateurs, 0 comptes, +0 / ~0, 0 erreur(s), 0 s | |
| 46 | −- **Throttling** : 0.5 s entre requêtes (`request_delay`) | |
| 45 | +- **Dernier passage** : 2026-08-18T09:50:40Z — 24 créateurs, 90 comptes, +0 / ~24, 0 erreur(s), 1 s | |
| 46 | +- **Throttling** : 1.0 s entre requêtes (`request_delay`) | |
| 47 | 47 | |
| 48 | 48 | ## Volumétrie & complétude |
| 49 | 49 | |
| 50 | 50 | - **Créateurs découverts par la source** (`doc.source`) : 0 · **fiches touchées** (`source_ids`) : 0 |
| 51 | −- **Comptes `twitch` en BD** : 26 — abonnés 15 % · métriques 0 % · dernière vérification 2026-08-18T06:27:51Z | |
| 52 | −- **Runs journalisés (60 derniers)** : 1, dont 0 avec erreurs | |
| 51 | +- **Comptes `twitch` en BD** : 28 — abonnés 75 % · métriques 68 % · dernière vérification 2026-08-18T10:07:33Z | |
| 52 | +- **Runs journalisés (60 derniers)** : 2, dont 0 avec erreurs | |
| 53 | 53 | |
| 54 | 54 | ## Erreurs connues & dépannage |
| 55 | 55 | |
@@ -61,11 +61,12 @@ Rejouer la source seule : `python3 run.py sync twitch` · vérifier `sync_log` ( | ||
| 61 | 61 | |
| 62 | 62 | ## Licence, attribution & conditions |
| 63 | 63 | |
| 64 | −- **Cadre d'accès (registre)** : API officielle Helix (client_credentials) — sauté sans clés | |
| 65 | −- **Profils publics uniquement** : UA identifiable `CreaKaBot/1.0 (+https://www.crea-ka.com/bot; contact@spboucher.ai)`, throttling poli, respect des opt-out (`data/optout.json`) et du volet éthique (`creaka/ethics.py` — mineurs exclus via `is_minor`). | |
| 64 | +- **Cadre d'accès (registre)** : API GQL publique du site web officiel (gql.twitch.tv, Client-ID public du client web — SANS clé, requêtes groupées 20 profils/POST) ; voie Helix officielle reprise si TWITCH_CLIENT_ID/SECRET fournis | |
| 65 | +- **API publique** utilisée selon ses conditions (pas de clé détournée, throttling poli) ; données limitées aux profils publics. | |
| 66 | 66 | - Retrait sur demande : contact@spboucher.ai (opt-out honoré à la prochaine ingestion). |
| 67 | 67 | |
| 68 | 68 | ## Historique |
| 69 | 69 | |
| 70 | +- 2026-08-18 — date mentionnée au registre (voir notes/statut). | |
| 70 | 71 | - 2026-08-18 — plus ancien passage journalisé dans `sync_log` (fenêtre des 60 derniers). |
| 71 | 72 | - 2026-08-18 — vague d'enrichissement : standardisation de la documentation des connecteurs (fiche générée par `scripts/gen_connector_docs.py`). |
modified
docs/connecteurs/wikidata-qc.md
+10 −10
@@ -1,6 +1,6 @@ | ||
| 1 | 1 | # `wikidata-qc` — connecteur decouverte (palier 3) |
| 2 | 2 | |
| 3 | −_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 03:30 — ne pas éditer à la main, régénérer._ | |
| 3 | +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 06:25 — ne pas éditer à la main, régénérer._ | |
| 4 | 4 | |
| 5 | 5 | **État : actif** · Famille : decouverte (discovery) · Backend : direct · Créateurs découverts : 73 · touchés : 73 |
| 6 | 6 | |
@@ -29,17 +29,17 @@ Source de **découverte** : produit des fiches `Creator` (tables `creators` + `a | ||
| 29 | 29 | |
| 30 | 30 | | Champ du doc créateur | Contenu | Renseigné | Exemple réel | |
| 31 | 31 | |---|---|---|---| |
| 32 | −| `display_name` | Nom public | 100 % | Nigel Braun | | |
| 33 | −| `bio` | Bio | 60 % | Capturing the natural beauty of chemistry Find me on YouTube: @NileRed/@NileBlue | | |
| 32 | +| `display_name` | Nom public | 100 % | xQc | | |
| 33 | +| `bio` | Bio | 75 % | Félix Lengyel, mieux connu sous le pseudonyme « xQc » (Félix Québec), né le 12 novembre 1… | | |
| 34 | 34 | | `region` | Région | 0 % | — | |
| 35 | 35 | | `city` | Ville | 0 % | — | |
| 36 | −| `niches` | Niches (liste) | 100 % | lifestyle | | |
| 37 | −| `languages` | Langues (liste) | 100 % | fr | | |
| 36 | +| `niches` | Niches (liste) | 100 % | gaming, lifestyle | | |
| 37 | +| `languages` | Langues (liste) | 100 % | en, fr | | |
| 38 | 38 | | `creator_type` | Type de créateur | 100 % | influenceur | |
| 39 | −| `primary_platform` | Plateforme principale | 100 % | youtube | | |
| 40 | −| `platforms` | Comptes sociaux (liste) | 99 % | youtube:@nilered, instagram:@nile.red, x:@nilered2 | | |
| 41 | −| `avatar_url` | Avatar | 63 % | https://scontent-yyz1-1.cdninstagram.com/v/t51.2885-19/441008830_1124225362150102_4410959… | | |
| 42 | −| `total_reach` | Portée totale (abonnés cumulés) | 63 % | 12245225 | | |
| 39 | +| `primary_platform` | Plateforme principale | 100 % | twitch | | |
| 40 | +| `platforms` | Comptes sociaux (liste) | 99 % | twitch:@xqc, youtube:@UCmDTrq0LNgPodDOFZiSbsww, x:@xqc | | |
| 41 | +| `avatar_url` | Avatar | 71 % | https://scontent-yyz1-1.cdninstagram.com/v/t51.2885-19/387770319_137990486044045_21612287… | | |
| 42 | +| `total_reach` | Portée totale (abonnés cumulés) | 64 % | 17220047 | | |
| 43 | 43 | | `link_in_bio_url` | Lien-en-bio | 19 % | — | |
| 44 | 44 | |
| 45 | 45 | ## Fréquence & budget |
@@ -52,7 +52,7 @@ Source de **découverte** : produit des fiches `Creator` (tables `creators` + `a | ||
| 52 | 52 | ## Volumétrie & complétude |
| 53 | 53 | |
| 54 | 54 | - **Créateurs découverts par la source** (`doc.source`) : 73 · **fiches touchées** (`source_ids`) : 73 |
| 55 | −- **Complétude clé (découverts)** : niches 100 % · avatar 63 % · portée 63 % · région 0 % | |
| 55 | +- **Complétude clé (découverts)** : niches 100 % · avatar 71 % · portée 64 % · région 0 % | |
| 56 | 56 | - **Runs journalisés (60 derniers)** : 9, dont 1 avec erreurs |
| 57 | 57 | |
| 58 | 58 | ## Erreurs connues & dépannage |
added
docs/connecteurs/wikipedia-qc.md
+73 −0
@@ -0,0 +1,73 @@ | ||
| 1 | +# `wikipedia-qc` — connecteur decouverte (palier 3) | |
| 2 | + | |
| 3 | +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 06:25 — ne pas éditer à la main, régénérer._ | |
| 4 | + | |
| 5 | +**État : actif** · Famille : decouverte (discovery) · Backend : direct · Créateurs découverts : 76 · touchés : 76 | |
| 6 | + | |
| 7 | +## Description de la source | |
| 8 | + | |
| 9 | +Connecteur DÉCOUVERTE — créateurs de contenu québécois recensés dans les CATÉGORIES de Wikipédia (fr + en), handles sociaux CURÉS tirés de l'item Wikidata de chaque article. Mode d'accès : API MediaWiki publique (action=query) + API Wikidata (wbgetentities) — sans clé, très fiable. Palier 3 (§9). Vague 3. | |
| 10 | + | |
| 11 | +- **Notes (registre)** : vague 3 — complète wikidata-qc (SPARQL) : catégories éditoriales → intro d'article (marqueur QC OBLIGATOIRE pour les catégories pan-canadiennes) → handles P2003/P7085/P2397/P2002/P5797/P2013 + site officiel P856 ; personnes décédées exclues, is_minor via P569, fiche seulement si ≥1 lien curé | |
| 12 | +- **Signal d'identité** : base_publique (0.85) (colonne `accounts.signal` / `confidence`) | |
| 13 | +- **Module** : `creaka/connectors/wikipedia_qc.py` — classe `WikipediaQcConnector` (`kind = "discovery"`) | |
| 14 | + | |
| 15 | +## Accès | |
| 16 | + | |
| 17 | +- **Accès (registre)** : API MediaWiki publique (fr+en, catégories de créateurs QC : Vidéaste web canadien, Humoriste/Blogueur québécois, Canadian YouTubers/podcasters/Twitch streamers/TikTokers…) + API Wikidata wbgetentities (handles sociaux curés de l'infobox) — sans clé | |
| 18 | +- **Type d'accès (code)** : API SPARQL publique (Wikidata) | |
| 19 | +- **Endpoint de base** : https://{lang}.wikipedia.org/w/api.php | |
| 20 | +- **URLs du module** : https://{lang}.wikipedia.org/w/api.php · https://www.wikidata.org/w/api.php · https://www.youtube.com/channel/{cid.strip( | |
| 21 | +- **Pagination** : réponse unique (pas de pagination) | |
| 22 | +- **Backend anti-bot / rendu** : requests direct (session UA CreaKaBot, throttling poli) | |
| 23 | +- **Politesse** : 1.0 s entre requêtes, timeout 45 s, UA `CreaKaBot/1.0 (+https://www.crea-ka.com/bot)` | |
| 24 | +- **Authentification** : clé(s) API requise(s) — voir statut/registre | |
| 25 | + | |
| 26 | +## Champs récupérés → schéma cible | |
| 27 | + | |
| 28 | +Source de **découverte** : produit des fiches `Creator` (tables `creators` + `accounts`). Complétude mesurée sur les 76 créateurs découverts par cette source (champ `doc.source`) ; exemple tiré d'une fiche réelle. | |
| 29 | + | |
| 30 | +| Champ du doc créateur | Contenu | Renseigné | Exemple réel | | |
| 31 | +|---|---|---|---| | |
| 32 | +| `display_name` | Nom public | 100 % | Adib Alkhalidey | | |
| 33 | +| `bio` | Bio | 100 % | Adib Alkhalidey est un humoriste, auteur-compositeur-interprète, réalisateur et acteur qu… | | |
| 34 | +| `region` | Région | 0 % | — | | |
| 35 | +| `city` | Ville | 0 % | — | | |
| 36 | +| `niches` | Niches (liste) | 100 % | humour | | |
| 37 | +| `languages` | Langues (liste) | 100 % | fr | | |
| 38 | +| `creator_type` | Type de créateur | 100 % | humoriste | | |
| 39 | +| `primary_platform` | Plateforme principale | 100 % | site-web | | |
| 40 | +| `platforms` | Comptes sociaux (liste) | 100 % | site-web:@adibalkhalidey.com | | |
| 41 | +| `avatar_url` | Avatar | 75 % | https://upload.wikimedia.org/wikipedia/commons/thumb/9/9c/IGP6508.jpg/500px-IGP6508.jpg?u… | | |
| 42 | +| `total_reach` | Portée totale (abonnés cumulés) | 0 % | — | | |
| 43 | +| `link_in_bio_url` | Lien-en-bio | 0 % | — | | |
| 44 | + | |
| 45 | +## Fréquence & budget | |
| 46 | + | |
| 47 | +- **Cadence déclarée (registre)** : hebdomadaire | |
| 48 | +- **Cadence observée** (médiane sync_log) : — | |
| 49 | +- **Dernier passage** : 2026-08-18T10:07:33Z — 111 créateurs, 247 comptes, +76 / ~35, 0 erreur(s), 58 s | |
| 50 | +- **Throttling** : 1.0 s entre requêtes (`request_delay`) | |
| 51 | + | |
| 52 | +## Volumétrie & complétude | |
| 53 | + | |
| 54 | +- **Créateurs découverts par la source** (`doc.source`) : 76 · **fiches touchées** (`source_ids`) : 76 | |
| 55 | +- **Complétude clé (découverts)** : niches 100 % · avatar 75 % · portée 0 % · région 0 % | |
| 56 | +- **Runs journalisés (60 derniers)** : 2, dont 0 avec erreurs | |
| 57 | + | |
| 58 | +## Erreurs connues & dépannage | |
| 59 | + | |
| 60 | +Aucune erreur ni alerte dans les 60 derniers runs journalisés. | |
| 61 | + | |
| 62 | +Rejouer la source seule : `python3 run.py sync wikipedia-qc` · vérifier `sync_log` (`SELECT * FROM sync_log WHERE source='wikipedia-qc' ORDER BY ts DESC LIMIT 5;`). | |
| 63 | + | |
| 64 | +## Licence, attribution & conditions | |
| 65 | + | |
| 66 | +- **Cadre d'accès (registre)** : API MediaWiki publique (fr+en, catégories de créateurs QC : Vidéaste web canadien, Humoriste/Blogueur québécois, Canadian YouTubers/podcasters/Twitch streamers/TikTokers…) + API Wikidata wbgetentities (handles sociaux curés de l'infobox) — sans clé | |
| 67 | +- **Licence** : données Wikidata sous CC0 — réutilisation libre, mention « Source : Wikidata » affichée par courtoisie. | |
| 68 | +- Retrait sur demande : contact@spboucher.ai (opt-out honoré à la prochaine ingestion). | |
| 69 | + | |
| 70 | +## Historique | |
| 71 | + | |
| 72 | +- 2026-08-18 — plus ancien passage journalisé dans `sync_log` (fenêtre des 60 derniers). | |
| 73 | +- 2026-08-18 — vague d'enrichissement : standardisation de la documentation des connecteurs (fiche générée par `scripts/gen_connector_docs.py`). | |
modified
docs/connecteurs/x-profil.md
+10 −9
@@ -1,6 +1,6 @@ | ||
| 1 | 1 | # `x-profil` — connecteur enrichissement (palier 3) |
| 2 | 2 | |
| 3 | −_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 03:30 — ne pas éditer à la main, régénérer._ | |
| 3 | +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 06:25 — ne pas éditer à la main, régénérer._ | |
| 4 | 4 | |
| 5 | 5 | **État : actif** · Famille : enrichissement (enrichment) · Backend : direct · Créateurs découverts : 0 · touchés : 0 |
| 6 | 6 | |
@@ -25,36 +25,37 @@ Connecteur ENRICHISSEMENT « x-profil » — abonnés X (Twitter) SANS clé via | ||
| 25 | 25 | |
| 26 | 26 | ## Champs récupérés → schéma cible |
| 27 | 27 | |
| 28 | −Cible d'**enrichissement** : colonnes de la table `accounts` pour la/les plateforme(s) `x` (186 comptes en BD, confiance moyenne 0.89). | |
| 28 | +Cible d'**enrichissement** : colonnes de la table `accounts` pour la/les plateforme(s) `x` (209 comptes en BD, confiance moyenne 0.88). | |
| 29 | 29 | |
| 30 | 30 | | Colonne `accounts` | Contenu | Renseignée | Exemple réel | |
| 31 | 31 | |---|---|---|---| |
| 32 | 32 | | `handle` | Handle | 100 % | xqc | |
| 33 | 33 | | `url` | URL du profil | 100 % | https://x.com/xqc | |
| 34 | −| `followers` | Abonnés | 10 % | 1598794 | | |
| 34 | +| `followers` | Abonnés | 9 % | 1598794 | | |
| 35 | 35 | | `verified` | Badge vérifié | 0 % | — | |
| 36 | 36 | | `confidence` | Confiance d'identité | 100 % | 0.9 | |
| 37 | 37 | | `signal` | Signal d'identité | 100 % | cross_link | |
| 38 | −| `metrics` | Métriques détaillées (JSON) | 9 % | {"following": 526, "x_checked": "2026-08-18T06:14:55Z"} | | |
| 39 | −| `last_checked` | Dernière vérification | 100 % | 2026-08-18T06:27:51Z | | |
| 38 | +| `metrics` | Métriques détaillées (JSON) | 10 % | {"following": 526, "x_checked": "2026-08-18T06:14:55Z"} | | |
| 39 | +| `last_checked` | Dernière vérification | 100 % | 2026-08-18T10:07:33Z | | |
| 40 | 40 | |
| 41 | 41 | ## Fréquence & budget |
| 42 | 42 | |
| 43 | 43 | - **Cadence déclarée (registre)** : quotidienne (watch), rotation hebdomadaire |
| 44 | −- **Cadence observée** (médiane sync_log) : ≈ 6 min | |
| 45 | −- **Dernier passage** : 2026-08-18T06:15:49Z — 9 créateurs, 35 comptes, +0 / ~9, 1 erreur(s), 68 s | |
| 44 | +- **Cadence observée** (médiane sync_log) : ≈ 10 min | |
| 45 | +- **Dernier passage** : 2026-08-18T09:55:31Z — 4 créateurs, 20 comptes, +0 / ~4, 146 erreur(s), 325 s | |
| 46 | 46 | - **Throttling** : 2.0 s entre requêtes (`request_delay`) |
| 47 | 47 | |
| 48 | 48 | ## Volumétrie & complétude |
| 49 | 49 | |
| 50 | 50 | - **Créateurs découverts par la source** (`doc.source`) : 0 · **fiches touchées** (`source_ids`) : 0 |
| 51 | −- **Comptes `x` en BD** : 186 — abonnés 10 % · métriques 9 % · dernière vérification 2026-08-18T06:27:51Z | |
| 52 | −- **Runs journalisés (60 derniers)** : 3, dont 3 avec erreurs | |
| 51 | +- **Comptes `x` en BD** : 209 — abonnés 9 % · métriques 10 % · dernière vérification 2026-08-18T10:07:33Z | |
| 52 | +- **Runs journalisés (60 derniers)** : 4, dont 4 avec erreurs | |
| 53 | 53 | |
| 54 | 54 | ## Erreurs connues & dépannage |
| 55 | 55 | |
| 56 | 56 | | Passage | Erreurs | Alerte (sync_log) | |
| 57 | 57 | |---|---|---| |
| 58 | +| 2026-08-18T09:55:31Z | 146 | — | | |
| 58 | 59 | | 2026-08-18T06:15:49Z | 1 | — | |
| 59 | 60 | | 2026-08-18T06:12:35Z | 171 | — | |
| 60 | 61 | | 2026-08-18T06:02:56Z | 1 | — | |
modified
docs/connecteurs/youtube-recherche.md
+8 −8
@@ -1,6 +1,6 @@ | ||
| 1 | 1 | # `youtube-recherche` — connecteur decouverte (palier 3) |
| 2 | 2 | |
| 3 | −_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 03:30 — ne pas éditer à la main, régénérer._ | |
| 3 | +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 06:25 — ne pas éditer à la main, régénérer._ | |
| 4 | 4 | |
| 5 | 5 | **État : actif** · Famille : decouverte (discovery) · Backend : direct · Créateurs découverts : 4595 · touchés : 4595 |
| 6 | 6 | |
@@ -38,22 +38,22 @@ Source de **découverte** : produit des fiches `Creator` (tables `creators` + `a | ||
| 38 | 38 | | `creator_type` | Type de créateur | 100 % | youtubeur | |
| 39 | 39 | | `primary_platform` | Plateforme principale | 100 % | youtube | |
| 40 | 40 | | `platforms` | Comptes sociaux (liste) | 100 % | youtube:@%c3%87acommenceaujourdhui-francet%c3%a9l | |
| 41 | −| `avatar_url` | Avatar | 90 % | https://yt3.ggpht.com/34WaPmV58PCfc9W7IvR_oAVkTcQrRyYMenz4phwmrE7LPm8f4zJfOIc2Jq4XkMZZsYj… | | |
| 41 | +| `avatar_url` | Avatar | 91 % | https://yt3.ggpht.com/34WaPmV58PCfc9W7IvR_oAVkTcQrRyYMenz4phwmrE7LPm8f4zJfOIc2Jq4XkMZZsYj… | | |
| 42 | 42 | | `total_reach` | Portée totale (abonnés cumulés) | 96 % | 1860000 | |
| 43 | 43 | | `link_in_bio_url` | Lien-en-bio | 0 % | — | |
| 44 | 44 | |
| 45 | −Cible d'**enrichissement** : colonnes de la table `accounts` pour la/les plateforme(s) `youtube` (4768 comptes en BD, confiance moyenne 0.98). | |
| 45 | +Cible d'**enrichissement** : colonnes de la table `accounts` pour la/les plateforme(s) `youtube` (4784 comptes en BD, confiance moyenne 0.98). | |
| 46 | 46 | |
| 47 | 47 | | Colonne `accounts` | Contenu | Renseignée | Exemple réel | |
| 48 | 48 | |---|---|---|---| |
| 49 | 49 | | `handle` | Handle | 100 % | nilered | |
| 50 | 50 | | `url` | URL du profil | 100 % | https://www.youtube.com/@nilered | |
| 51 | −| `followers` | Abonnés | 93 % | 10900000 | | |
| 51 | +| `followers` | Abonnés | 94 % | 10900000 | | |
| 52 | 52 | | `verified` | Badge vérifié | 0 % | — | |
| 53 | 53 | | `confidence` | Confiance d'identité | 100 % | 0.9 | |
| 54 | 54 | | `signal` | Signal d'identité | 100 % | cross_link | |
| 55 | −| `metrics` | Métriques détaillées (JSON) | 0 % | — | | |
| 56 | −| `last_checked` | Dernière vérification | 100 % | 2026-08-18T00:32:23Z | | |
| 55 | +| `metrics` | Métriques détaillées (JSON) | 6 % | {"yt_checked": "2026-08-18T10:08:15Z", "videos": 400, "views": 4647638686, "last_video": … | | |
| 56 | +| `last_checked` | Dernière vérification | 100 % | 2026-08-18T10:08:15Z | | |
| 57 | 57 | |
| 58 | 58 | ## Fréquence & budget |
| 59 | 59 | |
@@ -65,8 +65,8 @@ Cible d'**enrichissement** : colonnes de la table `accounts` pour la/les platefo | ||
| 65 | 65 | ## Volumétrie & complétude |
| 66 | 66 | |
| 67 | 67 | - **Créateurs découverts par la source** (`doc.source`) : 4595 · **fiches touchées** (`source_ids`) : 4595 |
| 68 | −- **Complétude clé (découverts)** : niches 100 % · avatar 90 % · portée 96 % · région 0 % | |
| 69 | −- **Comptes `youtube` en BD** : 4768 — abonnés 93 % · métriques 0 % · dernière vérification 2026-08-18T07:22:54Z | |
| 68 | +- **Complétude clé (découverts)** : niches 100 % · avatar 91 % · portée 96 % · région 0 % | |
| 69 | +- **Comptes `youtube` en BD** : 4784 — abonnés 94 % · métriques 6 % · dernière vérification 2026-08-18T10:24:39Z | |
| 70 | 70 | - **Runs journalisés (60 derniers)** : 3, dont 0 avec erreurs |
| 71 | 71 | |
| 72 | 72 | ## Erreurs connues & dépannage |
modified
docs/connecteurs/youtube.md
+21 −19
@@ -1,65 +1,67 @@ | ||
| 1 | 1 | # `youtube` — connecteur enrichissement (palier 2) |
| 2 | 2 | |
| 3 | −_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 03:30 — ne pas éditer à la main, régénérer._ | |
| 3 | +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 06:25 — ne pas éditer à la main, régénérer._ | |
| 4 | 4 | |
| 5 | 5 | **État : actif** · Famille : enrichissement (enrichment) · Backend : direct · Créateurs découverts : 0 · touchés : 0 |
| 6 | 6 | |
| 7 | 7 | ## Description de la source |
| 8 | 8 | |
| 9 | −Connecteur ENRICHISSEMENT YouTube — abonnés + badge par créateur. Mode d'accès : YouTube Data API v3 (OFFICIELLE, privilégiée §10) si YOUTUBE_API_KEY est définie ; sinon repli page publique /@handle (requêtes directes polies). Palier 2 du catalogue (§9). | |
| 9 | +Connecteur ENRICHISSEMENT YouTube — stats de chaîne par créateur. Mode d'accès : YouTube Data API v3 (OFFICIELLE, privilégiée §10) si YOUTUBE_API_KEY est définie ; sinon voie SANS clé complète (vague 3) : page publique de la chaîne + InnerTube « browse » (l'API interne que la page elle-même appelle, clé publique embarquée). Palier 2-3 (§9). | |
| 10 | 10 | |
| 11 | −- **Notes (registre)** : abonnés par chaîne | |
| 12 | −- **Signal d'identité** : api_officielle (0.95) quand confirmé par l'API (colonne `accounts.signal` / `confidence`) | |
| 11 | +- **Notes (registre)** : abonnés, nb de vidéos, vues totales (si affichées), date de la dernière vidéo (dormant >12 mois), description → bio, bannière + avatar, pays affiché | |
| 12 | +- **Signal d'identité** : api_officielle (0.95) quand confirmé par l'API ; la voie publique ne touche pas au score (colonne `accounts.signal` / `confidence`) | |
| 13 | 13 | - **Module** : `creaka/connectors/youtube.py` — classe `YouTubeConnector` (`kind = "enrichment"`) |
| 14 | 14 | |
| 15 | 15 | ## Accès |
| 16 | 16 | |
| 17 | −- **Accès (registre)** : YouTube Data API v3 (officielle) si YOUTUBE_API_KEY, sinon page publique /@handle | |
| 17 | +- **Accès (registre)** : YouTube Data API v3 (officielle) si YOUTUBE_API_KEY, sinon voie publique complète SANS clé (vague 3) : page de chaîne (ytInitialData) + InnerTube browse du panneau « À propos » (clé publique embarquée) + page /videos | |
| 18 | 18 | - **Type d'accès (code)** : API JSON |
| 19 | 19 | - **Endpoint de base** : https://www.googleapis.com/youtube/v3/channels?part=statistics,snippet&forHandle={h}&key={key} |
| 20 | −- **URLs du module** : https://www.googleapis.com/youtube/v3/channels · https://www.youtube.com/@{h} | |
| 21 | −- **Pagination** : réponse unique (pas de pagination) | |
| 20 | +- **URLs du module** : https://www.googleapis.com/youtube/v3/channels · https://www.youtube.com/@{h} · https://www.youtube.com/channel/{h} · https://www.youtube.com/youtubei/v1/browse | |
| 21 | +- **Pagination** : curseur / continuation | |
| 22 | 22 | - **Backend anti-bot / rendu** : requests direct (session UA CreaKaBot, throttling poli) |
| 23 | 23 | - **Politesse** : 1.0 s entre requêtes, timeout 30 s, UA `CreaKaBot/1.0 (+https://www.crea-ka.com/bot)` |
| 24 | −- **Authentification** : aucune — contenu public / API anonyme | |
| 24 | +- **Authentification** : clé(s) API requise(s) — voir statut/registre | |
| 25 | 25 | |
| 26 | 26 | ## Champs récupérés → schéma cible |
| 27 | 27 | |
| 28 | −Cible d'**enrichissement** : colonnes de la table `accounts` pour la/les plateforme(s) `youtube` (4768 comptes en BD, confiance moyenne 0.98). | |
| 28 | +Cible d'**enrichissement** : colonnes de la table `accounts` pour la/les plateforme(s) `youtube` (4784 comptes en BD, confiance moyenne 0.98). | |
| 29 | 29 | |
| 30 | 30 | | Colonne `accounts` | Contenu | Renseignée | Exemple réel | |
| 31 | 31 | |---|---|---|---| |
| 32 | 32 | | `handle` | Handle | 100 % | nilered | |
| 33 | 33 | | `url` | URL du profil | 100 % | https://www.youtube.com/@nilered | |
| 34 | −| `followers` | Abonnés | 93 % | 10900000 | | |
| 34 | +| `followers` | Abonnés | 94 % | 10900000 | | |
| 35 | 35 | | `verified` | Badge vérifié | 0 % | — | |
| 36 | 36 | | `confidence` | Confiance d'identité | 100 % | 0.9 | |
| 37 | 37 | | `signal` | Signal d'identité | 100 % | cross_link | |
| 38 | −| `metrics` | Métriques détaillées (JSON) | 0 % | — | | |
| 39 | −| `last_checked` | Dernière vérification | 100 % | 2026-08-18T00:32:23Z | | |
| 38 | +| `metrics` | Métriques détaillées (JSON) | 6 % | {"yt_checked": "2026-08-18T10:08:15Z", "videos": 400, "views": 4647638686, "last_video": … | | |
| 39 | +| `last_checked` | Dernière vérification | 100 % | 2026-08-18T10:08:15Z | | |
| 40 | 40 | |
| 41 | 41 | ## Fréquence & budget |
| 42 | 42 | |
| 43 | −- **Cadence déclarée (registre)** : hebdomadaire | |
| 44 | −- **Cadence observée** (médiane sync_log) : — | |
| 45 | −- **Dernier passage** : 2026-08-18T01:06:18Z — 1487 créateurs, 1576 comptes, +0 / ~1487, 0 erreur(s), 0 s | |
| 43 | +- **Cadence déclarée (registre)** : quotidienne (watch) — cap 300 chaînes/passage, rotation 14 j (≈4 200 comptes → convergence ~2 semaines) | |
| 44 | +- **Cadence observée** (médiane sync_log) : ≈ 4.7 h | |
| 45 | +- **Dernier passage** : 2026-08-18T10:24:39Z — 264 créateurs, 503 comptes, +0 / ~264, 36 erreur(s), 1001 s | |
| 46 | 46 | - **Throttling** : 1.0 s entre requêtes (`request_delay`) |
| 47 | 47 | |
| 48 | 48 | ## Volumétrie & complétude |
| 49 | 49 | |
| 50 | 50 | - **Créateurs découverts par la source** (`doc.source`) : 0 · **fiches touchées** (`source_ids`) : 0 |
| 51 | −- **Comptes `youtube` en BD** : 4768 — abonnés 93 % · métriques 0 % · dernière vérification 2026-08-18T07:22:54Z | |
| 52 | −- **Runs journalisés (60 derniers)** : 1, dont 0 avec erreurs | |
| 51 | +- **Comptes `youtube` en BD** : 4784 — abonnés 94 % · métriques 6 % · dernière vérification 2026-08-18T10:24:39Z | |
| 52 | +- **Runs journalisés (60 derniers)** : 3, dont 1 avec erreurs | |
| 53 | 53 | |
| 54 | 54 | ## Erreurs connues & dépannage |
| 55 | 55 | |
| 56 | −Aucune erreur ni alerte dans les 60 derniers runs journalisés. | |
| 56 | +| Passage | Erreurs | Alerte (sync_log) | | |
| 57 | +|---|---|---| | |
| 58 | +| 2026-08-18T10:24:39Z | 36 | — | | |
| 57 | 59 | |
| 58 | 60 | Rejouer la source seule : `python3 run.py sync youtube` · vérifier `sync_log` (`SELECT * FROM sync_log WHERE source='youtube' ORDER BY ts DESC LIMIT 5;`). |
| 59 | 61 | |
| 60 | 62 | ## Licence, attribution & conditions |
| 61 | 63 | |
| 62 | −- **Cadre d'accès (registre)** : YouTube Data API v3 (officielle) si YOUTUBE_API_KEY, sinon page publique /@handle | |
| 64 | +- **Cadre d'accès (registre)** : YouTube Data API v3 (officielle) si YOUTUBE_API_KEY, sinon voie publique complète SANS clé (vague 3) : page de chaîne (ytInitialData) + InnerTube browse du panneau « À propos » (clé publique embarquée) + page /videos | |
| 63 | 65 | - **API publique** utilisée selon ses conditions (pas de clé détournée, throttling poli) ; données limitées aux profils publics. |
| 64 | 66 | - Retrait sur demande : contact@spboucher.ai (opt-out honoré à la prochaine ingestion). |
| 65 | 67 | |
modified
scripts/gen_connector_docs.py
+1 −0
@@ -46,6 +46,7 @@ SOURCE_PLATFORMS = { | ||
| 46 | 46 | "x-profil": ("x",), |
| 47 | 47 | "balados-rss": ("podcast",), |
| 48 | 48 | "balados-itunes": ("podcast",), |
| 49 | + "palmares-apple": ("podcast",), | |
| 49 | 50 | "podcastindex": ("podcast",), |
| 50 | 51 | "onlyqueb": ("onlyfans",), |
| 51 | 52 | } |
added
tests/test_vague3.py
+183 −0
@@ -0,0 +1,183 @@ | ||
| 1 | +# ============================================================================== | |
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +# File: tests/test_vague3.py | |
| 4 | +# Desc: Tests vague 3 — Twitch GQL public sans clé, stats YouTube sans clé | |
| 5 | +# (page + InnerTube), découverte Wikipédia (catégories + Wikidata), | |
| 6 | +# palmarès Apple Podcasts Canada | |
| 7 | +# ============================================================================== | |
| 8 | +import json | |
| 9 | +from datetime import datetime, timezone | |
| 10 | + | |
| 11 | +from creaka.connectors.palmares_apple import feed_head_is_quebec | |
| 12 | +from creaka.connectors.twitch import gql_user_query, parse_gql_user | |
| 13 | +from creaka.connectors.wikipedia_qc import (accounts_from_claims, birth_year, | |
| 14 | + is_deceased) | |
| 15 | +from creaka.connectors.youtube import ago_to_iso, parse_about, parse_channel_page | |
| 16 | +from creaka.connectors.youtube_recherche import is_quebec | |
| 17 | + | |
| 18 | + | |
| 19 | +# --- marqueurs QC (régression vague 3) --------------------------------------------- | |
| 20 | + | |
| 21 | +def test_is_quebec_frontieres_de_mot(): | |
| 22 | + # « levis » matchait dans « television », « estrie » dans « tapestries » | |
| 23 | + assert not is_quebec("Canadian television journalist") | |
| 24 | + assert not is_quebec("She makes tapestries and pottery.") | |
| 25 | + assert is_quebec("Humoriste de Lévis") | |
| 26 | + assert is_quebec("Festival en Estrie chaque été") | |
| 27 | + assert is_quebec("vlogueuse québécoise à Montréal") | |
| 28 | + | |
| 29 | + | |
| 30 | +# --- twitch (GQL public) ---------------------------------------------------------- | |
| 31 | + | |
| 32 | +_GQL_OK = {"data": {"user": { | |
| 33 | + "id": "71092938", "login": "xqc", "displayName": "xQc", | |
| 34 | + "description": "Streamer québécois.", | |
| 35 | + "profileImageURL": "https://static-cdn.jtvnw.net/x-300x300.jpeg", | |
| 36 | + "followers": {"totalCount": 12535966}, | |
| 37 | + "roles": {"isPartner": True, "isAffiliate": False}, | |
| 38 | + "lastBroadcast": {"startedAt": "2026-08-18T08:03:23Z", | |
| 39 | + "game": {"displayName": "Just Chatting"}}}}} | |
| 40 | + | |
| 41 | + | |
| 42 | +def test_twitch_parse_gql_user(): | |
| 43 | + info = parse_gql_user(_GQL_OK) | |
| 44 | + assert info["followers"] == 12535966 | |
| 45 | + assert info["partner"] is True and info["affiliate"] is False | |
| 46 | + assert info["bio"] == "Streamer québécois." | |
| 47 | + assert info["avatar"].startswith("https://") | |
| 48 | + assert info["last_stream"] == "2026-08-18T08:03:23Z" | |
| 49 | + assert info["game"] == "Just Chatting" | |
| 50 | + | |
| 51 | + | |
| 52 | +def test_twitch_parse_gql_user_inexistant(): | |
| 53 | + # login libre/renommé : data.user = null → None (jamais d'exception) | |
| 54 | + assert parse_gql_user({"data": {"user": None}}) is None | |
| 55 | + assert parse_gql_user({}) is None | |
| 56 | + | |
| 57 | + | |
| 58 | +def test_twitch_gql_query_echappe_le_login(): | |
| 59 | + q = gql_user_query('haxor"} { evil')["query"] | |
| 60 | + assert '\\"' in q # json.dumps a échappé la tentative d'injection | |
| 61 | + assert json.loads("{\"q\": " + q.split("login: ", 1)[1].split(")")[0] + "}") | |
| 62 | + | |
| 63 | + | |
| 64 | +# --- youtube sans clé -------------------------------------------------------------- | |
| 65 | + | |
| 66 | +def test_ago_to_iso(): | |
| 67 | + now = datetime(2026, 8, 18, tzinfo=timezone.utc) | |
| 68 | + assert ago_to_iso("3 days ago", now) == "2026-08-15" | |
| 69 | + assert ago_to_iso("Streamed 2 weeks ago", now) == "2026-08-04" | |
| 70 | + assert ago_to_iso("1 year ago", now) == "2025-08-18" | |
| 71 | + assert ago_to_iso("n'importe quoi") is None | |
| 72 | + | |
| 73 | + | |
| 74 | +_VIEW_MODEL = { | |
| 75 | + "metadata": {"contentMetadataViewModel": {"metadataRows": [ | |
| 76 | + {"metadataParts": [{"text": {"content": "@chainetest"}}]}, | |
| 77 | + {"metadataParts": [{"text": {"content": "1.2M subscribers"}}, | |
| 78 | + {"text": {"content": "266 videos"}}]}, | |
| 79 | + ]}}, | |
| 80 | + "banner": {"imageBannerViewModel": {"image": {"sources": [ | |
| 81 | + {"url": "https://yt3.example/banner.jpg", "width": 2120}]}}}, | |
| 82 | + "description": {"descriptionPreviewViewModel": {"rendererContext": { | |
| 83 | + "commandContext": {"onTap": {"innertubeCommand": { | |
| 84 | + "showEngagementPanelEndpoint": {"engagementPanel": { | |
| 85 | + "continuationItemRenderer": {"continuationEndpoint": { | |
| 86 | + "continuationCommand": {"token": "TOK_ABOUT"}}}}}}}}}}}, | |
| 87 | +} | |
| 88 | +_YTID = { | |
| 89 | + "metadata": {"channelMetadataRenderer": { | |
| 90 | + "title": "Chaîne Test", "externalId": "UC" + "a" * 22, | |
| 91 | + "description": "Vlog québécois.", | |
| 92 | + "avatar": {"thumbnails": [ | |
| 93 | + {"url": "https://yt3.example/avatar.jpg", "width": 160}]}}}, | |
| 94 | + "header": {"pageHeaderRenderer": { | |
| 95 | + "content": {"pageHeaderViewModel": _VIEW_MODEL}}}, | |
| 96 | +} | |
| 97 | +_PAGE = ("<html><script>var config = {\"INNERTUBE_API_KEY\":\"AIzaKEY\"," | |
| 98 | + "\"INNERTUBE_CONTEXT_CLIENT_VERSION\":\"2.2026\"};</script>" | |
| 99 | + "<script>var ytInitialData = " + json.dumps(_YTID) | |
| 100 | + + ";</script></html>") | |
| 101 | + | |
| 102 | + | |
| 103 | +def test_parse_channel_page(): | |
| 104 | + page = parse_channel_page(_PAGE) | |
| 105 | + assert page["followers"] == 1_200_000 | |
| 106 | + assert page["videos"] == 266 | |
| 107 | + assert page["channel_id"] == "UC" + "a" * 22 | |
| 108 | + assert page["description"] == "Vlog québécois." | |
| 109 | + assert page["avatar"] == "https://yt3.example/avatar.jpg" | |
| 110 | + assert page["banner"] == "https://yt3.example/banner.jpg" | |
| 111 | + assert page["about_token"] == "TOK_ABOUT" | |
| 112 | + assert page["key"] == "AIzaKEY" and page["ver"] == "2.2026" | |
| 113 | + | |
| 114 | + | |
| 115 | +def test_parse_channel_page_illisible(): | |
| 116 | + assert parse_channel_page("<html>rien</html>") is None | |
| 117 | + | |
| 118 | + | |
| 119 | +def test_parse_about(): | |
| 120 | + text = ('{"aboutChannelViewModel":{"viewCountText":"687,115,991 views",' | |
| 121 | + '"videoCountText":"266 videos","country":{"content":"Canada"}}}') | |
| 122 | + about = parse_about(text) | |
| 123 | + assert about == {"views": 687_115_991, "videos": 266, "country": "Canada"} | |
| 124 | + # réponse sans panneau À propos (jeton périmé) → None, jamais de bruit | |
| 125 | + assert parse_about('{"contents":{}}') is None | |
| 126 | + | |
| 127 | + | |
| 128 | +# --- wikipedia-qc ------------------------------------------------------------------ | |
| 129 | + | |
| 130 | +def _claim(value): | |
| 131 | + return {"mainsnak": {"datavalue": {"value": value}}} | |
| 132 | + | |
| 133 | + | |
| 134 | +def test_accounts_from_claims(): | |
| 135 | + claims = { | |
| 136 | + "P2003": [_claim("jadelavoie")], # instagram | |
| 137 | + "P2397": [_claim("UC" + "b" * 22)], # chaîne youtube | |
| 138 | + "P856": [_claim("https://www.exemple.qc.ca/")], # site officiel | |
| 139 | + } | |
| 140 | + accounts = {a.platform: a for a in accounts_from_claims(claims)} | |
| 141 | + assert accounts["instagram"].handle == "jadelavoie" | |
| 142 | + assert accounts["instagram"].signal == "base_publique" | |
| 143 | + assert accounts["youtube"].url.endswith("/channel/UC" + "b" * 22) | |
| 144 | + assert accounts["site-web"].handle == "exemple.qc.ca" | |
| 145 | + assert all(a.confidence == 0.85 for a in accounts.values()) | |
| 146 | + | |
| 147 | + | |
| 148 | +def test_birth_year_et_deces(): | |
| 149 | + claims = {"P569": [_claim({"time": "+1972-01-14T00:00:00Z"})]} | |
| 150 | + assert birth_year(claims) == 1972 | |
| 151 | + assert not is_deceased(claims) | |
| 152 | + assert is_deceased({"P570": [_claim({"time": "+2001-01-01T00:00:00Z"})]}) | |
| 153 | + assert birth_year({}) is None | |
| 154 | + | |
| 155 | + | |
| 156 | +# --- palmares-apple ---------------------------------------------------------------- | |
| 157 | + | |
| 158 | +_RSS_QC = ("""<rss><channel><title>Mon balado</title> | |
| 159 | +<language>fr-ca</language> | |
| 160 | +<description>Discussions à Montréal chaque semaine.</description> | |
| 161 | +<itunes:author>Équipe</itunes:author> | |
| 162 | +<item><title>Épisode 1</title></item></channel></rss>""") | |
| 163 | + | |
| 164 | +_RSS_HORS_QC = ("""<rss><channel><title>The Daily</title> | |
| 165 | +<language>en-us</language> | |
| 166 | +<description>This is what the news should sound like.</description> | |
| 167 | +<item><title>Episode about montreal bagels</title></item></channel></rss>""") | |
| 168 | + | |
| 169 | +# balado ANGLO qui mentionne Montréal dans sa description (dates de tournée) : | |
| 170 | +# la langue non francophone doit suffire à l'écarter (faux positif vague 3) | |
| 171 | +_RSS_TOURNEE = ("""<rss><channel><title>Comedy Show</title> | |
| 172 | +<language>en</language> | |
| 173 | +<description>Live shows in Toronto, Montreal and Vancouver!</description> | |
| 174 | +<item><title>Ep 1</title></item></channel></rss>""") | |
| 175 | + | |
| 176 | + | |
| 177 | +def test_feed_head_is_quebec_langue_et_marqueur_exiges(): | |
| 178 | + assert feed_head_is_quebec(_RSS_QC) | |
| 179 | + # le marqueur dans un ÉPISODE ne suffit jamais (en-tête du canal seulement) | |
| 180 | + assert not feed_head_is_quebec(_RSS_HORS_QC) | |
| 181 | + # marqueur dans l'en-tête MAIS canal anglophone → jamais rattaché | |
| 182 | + assert not feed_head_is_quebec(_RSS_TOURNEE) | |
| 183 | + assert not feed_head_is_quebec("") | |
| 184 | ||