SPB Git

spb/crea-ka

Public

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

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>
Simon-Pierre Boucher committed (Aug 18, 2026) parent 1b5c1b3 Browse files

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 # Author: Simon-Pierre Boucher <contact@spboucher.ai> 2 # Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 # File: creaka/connectors/twitch.py 3 # File: creaka/connectors/twitch.py
4−# Desc: Connecteur ENRICHISSEMENT Twitch — existence + profil par créateur 4+# Desc: Connecteur ENRICHISSEMENT Twitch — profil public par créateur SANS
5−# via l'API OFFICIELLE Helix (client_credentials). Palier 2 (§9, §10). 5+# clé via l'API GQL publique du site web officiel (Client-ID public),
6−# Sans TWITCH_CLIENT_ID/TWITCH_CLIENT_SECRET : passage sauté proprement. 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+Le site web officiel de Twitch consomme ``gql.twitch.tv/gql`` avec un
11−et sa description. (Le total d'abonnés de chaîne exige un jeton de modérateur 12+Client-ID PUBLIC embarqué dans chaque page servie à n'importe quel visiteur
12−depuis 2023 — on ne le collecte pas plutôt que de contourner, §15.) 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 from __future__ import annotations 28 from __future__ import annotations
15 29
30+import json
16 import os 31 import os
32+import subprocess
33+import time
17 34
18 from ..schema import Creator, now_iso 35 from ..schema import Creator, now_iso
19−from .base import BaseConnector, SkipSource 36+from .base import USER_AGENT, BaseConnector
20 37
21 TOKEN_URL = "https://id.twitch.tv/oauth2/token" 38 TOKEN_URL = "https://id.twitch.tv/oauth2/token"
22 USERS_URL = "https://api.twitch.tv/helix/users" 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 class TwitchConnector(BaseConnector): 77 class TwitchConnector(BaseConnector):
26 source_id = "twitch" 78 source_id = "twitch"
27 kind = "enrichment" 79 kind = "enrichment"
28− request_delay = 0.5 80+ request_delay = 1.0
29 max_profiles = 400 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 def _token(self, cid: str, secret: str) -> str: 89 def _token(self, cid: str, secret: str) -> str:
32 resp = self.post(TOKEN_URL, data={"client_id": cid, 90 resp = self.post(TOKEN_URL, data={"client_id": cid,
33 "client_secret": secret, 91 "client_secret": secret,
34 "grant_type": "client_credentials"}) 92 "grant_type": "client_credentials"})
35 return resp.json()["access_token"] 93 return resp.json()["access_token"]
36 94
37− def enrich(self, creators: list[Creator]) -> list[Creator]: 95+ def _enrich_helix(self, pairs: list, cid: str, secret: str) -> 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)")
45 token = self._token(cid, secret) 96 token = self._token(cid, secret)
46 headers = {"Client-Id": cid, "Authorization": f"Bearer {token}"} 97 headers = {"Client-Id": cid, "Authorization": f"Bearer {token}"}
47 enriched: list[Creator] = [] 98 enriched: list[Creator] = []
48− fetched = 0 99+ for cr, tw in pairs:
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
53 try: 100 try:
54 resp = self.get(USERS_URL, params={"login": tw.handle}, 101 resp = self.get(USERS_URL, params={"login": tw.handle},
55 headers=headers) 102 headers=headers)
56 except Exception: 103 except Exception:
104+ self.errors += 1
57 continue 105 continue
58− fetched += 1
59 data = resp.json().get("data") or [] 106 data = resp.json().get("data") or []
60 if not data: 107 if not data:
61 continue 108 continue
@@ -66,5 +113,84 @@ class TwitchConnector(BaseConnector):
66 tw.last_checked = now_iso() 113 tw.last_checked = now_iso()
67 if not cr.bio and user.get("description"): 114 if not cr.bio and user.get("description"):
68 cr.bio = user["description"] 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 enriched.append(cr) 118 enriched.append(cr)
70 return enriched 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 # Author: Simon-Pierre Boucher <contact@spboucher.ai> 2 # Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 # File: creaka/connectors/youtube.py 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 # Mode d'accès : YouTube Data API v3 (OFFICIELLE, privilégiée §10) si 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 6+# YOUTUBE_API_KEY est définie ; sinon voie SANS clé complète (vague 3) :
7−# (requêtes directes polies). Palier 2 du catalogue (§9). 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 """Enrichissement YouTube, créateur par créateur. 10 """Enrichissement YouTube, créateur par créateur.
10 11
11 Voie privilégiée : l'API officielle `channels?forHandle=` (fiable, conforme, 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+quota 10 000 unités/jour). Sans clé, voie publique complète (vague 3) :
13−repli léger : la page publique youtube.com/@handle expose le nombre d'abonnés 14+
14−dans ses métadonnées. Signal : `api_officielle` (0.95) quand confirmé par 15+1. page ``youtube.com/@handle`` (ytInitialData) → abonnés, nb de vidéos,
15−l'API — le handle interrogé est celui déjà rattaché. 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 from __future__ import annotations 28 from __future__ import annotations
18 29
30+import json
19 import os 31 import os
20 import re 32 import re
33+from datetime import datetime, timedelta, timezone
21 34
22 from ..normalize import parse_count 35 from ..normalize import parse_count
23 from ..schema import Creator, now_iso 36 from ..schema import Creator, now_iso
@@ -26,20 +39,132 @@ from .base import BaseConnector
26 API_URL = ("https://www.googleapis.com/youtube/v3/channels" 39 API_URL = ("https://www.googleapis.com/youtube/v3/channels"
27 "?part=statistics,snippet&forHandle={h}&key={key}") 40 "?part=statistics,snippet&forHandle={h}&key={key}")
28 PAGE_URL = "https://www.youtube.com/@{h}" 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 _SUBS_RES = ( 49 _SUBS_RES = (
30 re.compile(r'"subscriberCountText"[^}]*?"(?:simpleText|content)"\s*:\s*"([^"]+)"'), 50 re.compile(r'"subscriberCountText"[^}]*?"(?:simpleText|content)"\s*:\s*"([^"]+)"'),
31 re.compile(r'"subscriberCount"\s*:\s*"?(\d+)"?'), 51 re.compile(r'"subscriberCount"\s*:\s*"?(\d+)"?'),
32− re.compile(r'"metadataParts".{0,200}?"content"\s*:\s*"([\d.,\s]+[kKmM]?\s*' 52+ re.compile(r'"content"\s*:\s*"([\d.,\s]+[kKmMbB]?\s*(?:subscribers|abonn[ée]s))"'),
33− r'(?: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 class YouTubeConnector(BaseConnector): 155 class YouTubeConnector(BaseConnector):
38 source_id = "youtube" 156 source_id = "youtube"
39 kind = "enrichment" 157 kind = "enrichment"
40 request_delay = 1.0 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 def _via_api(self, handle: str, key: str) -> dict | None: 168 def _via_api(self, handle: str, key: str) -> dict | None:
44 resp = self.get(API_URL.format(h=handle, key=key)) 169 resp = self.get(API_URL.format(h=handle, key=key))
45 items = resp.json().get("items") or [] 170 items = resp.json().get("items") or []
@@ -51,18 +176,72 @@ class YouTubeConnector(BaseConnector):
51 "videos": parse_count(stats.get("videoCount")), 176 "videos": parse_count(stats.get("videoCount")),
52 "confirmed": True} 177 "confirmed": True}
53 178
54− def _via_page(self, handle: str) -> dict | None: 179+ # -- voie publique sans clé (vague 3) -----------------------------------------
55− resp = self.get(PAGE_URL.format(h=handle), 180+ def _channel_url(self, acc) -> str:
56− headers={"Accept-Language": "en-CA,en;q=0.8"}) 181+ if _UCID_RE.fullmatch(acc.handle):
57− html = resp.text 182+ return CHANNEL_URL.format(h=acc.handle)
58− for rx in _SUBS_RES: 183+ return PAGE_URL.format(h=acc.handle)
59− m = rx.search(html) 184+
60− if m: 185+ def _browse_about(self, key: str, ver: str, token: str) -> dict | None:
61− return {"followers": parse_count(m.group(1)), "confirmed": False} 186+ resp = self.post(
62− return None 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 def enrich(self, creators: list[Creator]) -> list[Creator]: 238 def enrich(self, creators: list[Creator]) -> list[Creator]:
65 key = os.environ.get("YOUTUBE_API_KEY", "") 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 enriched: list[Creator] = [] 245 enriched: list[Creator] = []
67 fetched = 0 246 fetched = 0
68 for cr in creators: 247 for cr in creators:
@@ -70,9 +249,9 @@ class YouTubeConnector(BaseConnector):
70 if yt is None or fetched >= self.max_profiles: 249 if yt is None or fetched >= self.max_profiles:
71 continue 250 continue
72 try: 251 try:
73− info = self._via_api(yt.handle, key) if key \ 252+ info = self._via_api(yt.handle, key)
74− else self._via_page(yt.handle)
75 except Exception: 253 except Exception:
254+ self.errors += 1
76 continue 255 continue
77 fetched += 1 256 fetched += 1
78 if not info or info.get("followers") is None: 257 if not info or info.get("followers") is None:
@@ -81,9 +260,60 @@ class YouTubeConnector(BaseConnector):
81 yt.last_checked = now_iso() 260 yt.last_checked = now_iso()
82 extra = {k: info.get(k) for k in ("views", "videos")} 261 extra = {k: info.get(k) for k in ("views", "videos")}
83 yt.metrics.update({k: v for k, v in extra.items() if v is not None}) 262 yt.metrics.update({k: v for k, v in extra.items() if v is not None})
84− if info.get("confirmed"): 263+ # existence confirmée par l'API officielle → signal renforcé
85− # existence confirmée par l'API officielle → signal renforcé 264+ yt.confidence = max(yt.confidence or 0, 0.90)
86− yt.confidence = max(yt.confidence or 0, 0.90) 265+ yt.signal = yt.signal or "api_officielle"
87− 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 enriched.append(cr) 318 enriched.append(cr)
89 return enriched 319 return enriched
modified creaka/connectors/youtube_recherche.py +7 −5
@@ -34,12 +34,14 @@ _KEY_RE = re.compile(r'"INNERTUBE_API_KEY":"([^"]+)"')
34 _VER_RE = re.compile(r'"INNERTUBE_CONTEXT_CLIENT_VERSION":"([^"]+)"') 34 _VER_RE = re.compile(r'"INNERTUBE_CONTEXT_CLIENT_VERSION":"([^"]+)"')
35 35
36 # marqueurs québécois (comparés SANS accents, en minuscules) 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 _QC_MARKERS = re.compile( 39 _QC_MARKERS = re.compile(
38− r"queb|montreal|\bmtl\b|saguenay|gaspesie|mauricie|estrie|outaouais|" 40+ r"queb|montreal|\bmtl\b|saguenay|gaspesie|mauricie|\bestrie\b|outaouais|"
39− r"abitibi|levis|sherbrooke|trois-rivieres|gatineau|\blaval\b|longueuil|" 41+ r"abitibi|\blevis\b|sherbrooke|trois-rivieres|gatineau|\blaval\b|longueuil|"
40− r"rimouski|chicoutimi|drummondville|granby|beauce|charlevoix|lanaudiere|" 42+ r"rimouski|chicoutimi|drummondville|\bgranby\b|\bbeauce\b|charlevoix|"
41− r"laurentides|monteregie|temiscamingue|joliette|victoriaville|" 43+ r"lanaudiere|laurentides|monteregie|temiscamingue|\bjoliette\b|"
42− r"\bfleuve\b|rive-sud|rive-nord|ville de quebec") 44+ r"victoriaville|\bfleuve\b|rive-sud|rive-nord|ville de quebec")
43 45
44 # requêtes thématiques → niche par défaut de la fiche 46 # requêtes thématiques → niche par défaut de la fiche
45 QUERIES: list[tuple[str, str]] = [ 47 QUERIES: list[tuple[str, str]] = [
modified data/sources.json +25 −7
@@ -40,6 +40,24 @@
40 "cadence": "quotidienne (watch) — la couverture s'accumule à chaque passage", 40 "cadence": "quotidienne (watch) — la couverture s'accumule à chaque passage",
41 "notes": "filtre conservateur : marqueur québécois requis dans titre/description de la chaîne" 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 "id": "balados-itunes", 62 "id": "balados-itunes",
45 "famille": "decouverte", 63 "famille": "decouverte",
@@ -71,19 +89,19 @@
71 "id": "youtube", 89 "id": "youtube",
72 "famille": "enrichissement", 90 "famille": "enrichissement",
73 "palier": 2, 91 "palier": 2,
74− "acces": "YouTube Data API v3 (officielle) si YOUTUBE_API_KEY, sinon page publique /@handle", 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",
75− "signal_identite": "api_officielle (0.95) quand confirmé par l'API", 93+ "signal_identite": "api_officielle (0.95) quand confirmé par l'API ; la voie publique ne touche pas au score",
76− "cadence": "hebdomadaire", 94+ "cadence": "quotidienne (watch) — cap 300 chaînes/passage, rotation 14 j (≈4 200 comptes → convergence ~2 semaines)",
77− "notes": "abonnés par chaîne" 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 "id": "twitch", 98 "id": "twitch",
81 "famille": "enrichissement", 99 "famille": "enrichissement",
82 "palier": 2, 100 "palier": 2,
83− "acces": "API officielle Helix (client_credentials) — sauté sans clés", 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",
84− "signal_identite": "api_officielle (0.95)", 102+ "signal_identite": "api_officielle (0.95) via Helix ; la voie GQL publique ne touche pas au score",
85 "cadence": "hebdomadaire", 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 "id": "link-in-bio", 107 "id": "link-in-bio",
modified docs/connecteurs/INDEX.md +15 −13
@@ -1,22 +1,24 @@
1 # Créa-Ka — Index des connecteurs 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 | Source | Famille | Palier | Type d'accès | Backend | Découverts | Comptes (plateforme) | État | Dernier passage | 7 | Source | Famille | Palier | Type d'accès | Backend | Découverts | Comptes (plateforme) | État | Dernier passage |
8 |---|---|---|---|---|---|---|---|---| 8 |---|---|---|---|---|---|---|---|---|
9 | [`listes-medias`](listes-medias.md) | decouverte | P3 | dataset local compilé (data/seed) | direct | 310 | — | actif | 2026-08-18T06:23… | 9 | [`listes-medias`](listes-medias.md) | decouverte | P3 | dataset local compilé (data/seed) | direct | 310 | — | actif | 2026-08-18T06:23… |
10 | [`wikidata-qc`](wikidata-qc.md) | decouverte | P3 | API SPARQL publique (Wikidata) | direct | 73 | — | actif | 2026-08-18T06:27… | 10 | [`wikidata-qc`](wikidata-qc.md) | decouverte | P3 | API SPARQL publique (Wikidata) | direct | 73 | — | actif | 2026-08-18T06:27… |
11 | [`onlyqueb`](onlyqueb.md) | decouverte | P3 | sitemap XML + JSON-LD des pages profil (S… | direct | 253 | 248 (onlyfans) | actif | 2026-08-18T06:27… | 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… | 12+| [`youtube-recherche`](youtube-recherche.md) | decouverte | P3 | API JSON | direct | 4595 | 4784 (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… | 13+| [`wikipedia-qc`](wikipedia-qc.md) | decouverte | P3 | API SPARQL publique (Wikidata) | direct | 76 | — | actif | 2026-08-18T10:07… |
14−| [`instagram-profil`](instagram-profil.md) | enrichissement | P4 | pages HTML publiques | Scrapfly | 0 | 460 (instagram) | actif | 2026-08-17T23:23… | 14+| [`palmares-apple`](palmares-apple.md) | decouverte | P2 | API iTunes Search/Lookup (publique) | direct | 0 | 435 (podcast) | actif | 2026-08-18T09:58… |
15−| [`tiktok-profil`](tiktok-profil.md) | enrichissement | P4 | pages HTML publiques | Scrapfly | 0 | 197 (tiktok) | actif | 2026-08-18T00:31… | 15+| [`balados-itunes`](balados-itunes.md) | decouverte | P2 | API iTunes Search/Lookup (publique) | direct | 433 | 435 (podcast) | actif | 2026-08-18T06:23… |
16−| [`youtube`](youtube.md) | enrichissement | P2 | API JSON | direct | 0 | 4768 (youtube) | actif | 2026-08-18T01:06… | 16+| [`instagram-profil`](instagram-profil.md) | enrichissement | P4 | pages HTML publiques | Scrapfly | 0 | 476 (instagram) | actif | 2026-08-18T07:51… |
17−| [`twitch`](twitch.md) | enrichissement | P2 | API JSON | direct | 0 | 26 (twitch) | actif | 2026-08-18T01:06… | 17+| [`tiktok-profil`](tiktok-profil.md) | enrichissement | P4 | pages HTML publiques | Scrapfly | 0 | 199 (tiktok) | actif | 2026-08-18T09:16… |
18−| [`link-in-bio`](link-in-bio.md) | enrichissement | P1 | pages HTML publiques | Firecrawl | 0 | — | actif | 2026-08-18T06:20… | 18+| [`youtube`](youtube.md) | enrichissement | P2 | API JSON | direct | 0 | 4784 (youtube) | actif | 2026-08-18T10:24… |
19−| [`balados-rss`](balados-rss.md) | enrichissement | P1 | flux RSS publics | direct | 0 | 434 (podcast) | actif | 2026-08-18T06:17… | 19+| [`twitch`](twitch.md) | enrichissement | P2 | API JSON | direct | 0 | 28 (twitch) | actif | 2026-08-18T09:50… |
20−| [`podcastindex`](podcastindex.md) | enrichissement | P2 | API JSON | direct | 0 | 434 (podcast) | prêt | — | 20+| [`link-in-bio`](link-in-bio.md) | enrichissement | P1 | pages HTML publiques | Firecrawl | 0 | — | actif | 2026-08-18T09:57… |
21−| [`x-profil`](x-profil.md) | enrichissement | P3 | pages HTML publiques | direct | 0 | 186 (x) | actif | 2026-08-18T06:15… | 21+| [`balados-rss`](balados-rss.md) | enrichissement | P1 | flux RSS publics | direct | 0 | 435 (podcast) | actif | 2026-08-18T09:55… |
22−| [`bio-liens`](bio-liens.md) | enrichissement | P1 | pages HTML publiques | direct | 0 | — | actif | 2026-08-18T06:17… | 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 # `balados-itunes` — connecteur decouverte (palier 2) 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 **État : actif** · Famille : decouverte (discovery) · Backend : direct · Créateurs découverts : 433 · touchés : 433 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 | Champ du doc créateur | Contenu | Renseigné | Exemple réel | 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 | 32+| `display_name` | Nom public | 100 % | Québec Nostalgie : le podcast |
33−| `bio` | Bio | 97 % | Le balado qui met l’humain au cœur des transferts d’entreprise. Dans les années à venir, … | 33+| `bio` | Bio | 97 % | Fidèle à la page Instagram du même nom, Québec Nostalgie : le podcast est un véritable vo… |
34 | `region` | Région | 0 % | — | 34 | `region` | Région | 0 % | — |
35 | `city` | Ville | 0 % | — | 35 | `city` | Ville | 0 % | — |
36−| `niches` | Niches (liste) | 100 % | actualite-opinion, arts, finance-affaires | 36+| `niches` | Niches (liste) | 100 % | actualite-opinion |
37 | `languages` | Langues (liste) | 100 % | fr | 37 | `languages` | Langues (liste) | 100 % | fr |
38 | `creator_type` | Type de créateur | 100 % | podcasteur | 38 | `creator_type` | Type de créateur | 100 % | podcasteur |
39−| `primary_platform` | Plateforme principale | 100 % | youtube | 39+| `primary_platform` | Plateforme principale | 100 % | instagram |
40−| `platforms` | Comptes sociaux (liste) | 100 % | youtube:@releveetrepreneuriat, podcast:@1889244567 | 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/66/d3/96/66d3962a-1201-b271-25bb-… | 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 % | 176 | 42+| `total_reach` | Portée totale (abonnés cumulés) | 0 % | 109744 |
43−| `link_in_bio_url` | Lien-en-bio | 0 % | — | 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 | Colonne `accounts` | Contenu | Renseignée | Exemple réel | 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 - **Créateurs découverts par la source** (`doc.source`) : 433 · **fiches touchées** (`source_ids`) : 433 67 - **Créateurs découverts par la source** (`doc.source`) : 433 · **fiches touchées** (`source_ids`) : 433
68 - **Complétude clé (découverts)** : niches 100 % · avatar 100 % · portée 0 % · région 0 % 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 - **Runs journalisés (60 derniers)** : 7, dont 0 avec erreurs 70 - **Runs journalisés (60 derniers)** : 7, dont 0 avec erreurs
71 71
72 ## Erreurs connues & dépannage 72 ## Erreurs connues & dépannage
modified docs/connecteurs/balados-rss.md +7 −6
@@ -1,6 +1,6 @@
1 # `balados-rss` — connecteur enrichissement (palier 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 **État : actif** · Famille : enrichissement (enrichment) · Backend : direct · Créateurs découverts : 0 · touchés : 0 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 ## Champs récupérés → schéma cible 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 | Colonne `accounts` | Contenu | Renseignée | Exemple réel | 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 ## Fréquence & budget 41 ## Fréquence & budget
42 42
43 - **Cadence déclarée (registre)** : quotidienne (watch) — rotation complète des 433 balados en ~3 jours, puis hebdomadaire 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 44+- **Cadence observée** (médiane sync_log) : ≈ 2.0 h
45−- **Dernier passage** : 2026-08-18T06:17:19Z — 8 créateurs, 36 comptes, +0 / ~8, 5 erreur(s), 12 s 45+- **Dernier passage** : 2026-08-18T09:55:43Z — 8 créateurs, 36 comptes, +0 / ~8, 5 erreur(s), 12 s
46 - **Throttling** : 0.2 s entre requêtes (`request_delay`) 46 - **Throttling** : 0.2 s entre requêtes (`request_delay`)
47 47
48 ## Volumétrie & complétude 48 ## Volumétrie & complétude
49 49
50 - **Créateurs découverts par la source** (`doc.source`) : 0 · **fiches touchées** (`source_ids`) : 0 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 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)** : 4, dont 3 avec erreurs 52+- **Runs journalisés (60 derniers)** : 5, dont 4 avec erreurs
53 53
54 ## Erreurs connues & dépannage 54 ## Erreurs connues & dépannage
55 55
56 | Passage | Erreurs | Alerte (sync_log) | 56 | Passage | Erreurs | Alerte (sync_log) |
57 |---|---|---| 57 |---|---|---|
58+| 2026-08-18T09:55:43Z | 5 | — |
58 | 2026-08-18T06:17:19Z | 5 | — | 59 | 2026-08-18T06:17:19Z | 5 | — |
59 | 2026-08-18T06:17:07Z | 5 | — | 60 | 2026-08-18T06:17:07Z | 5 | — |
60 | 2026-08-18T06:16:42Z | 5 | — | 61 | 2026-08-18T06:16:42Z | 5 | — |
modified docs/connecteurs/bio-liens.md +3 −3
@@ -1,6 +1,6 @@
1 # `bio-liens` — connecteur enrichissement (palier 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 **État : actif** · Famille : enrichissement (enrichment) · Backend : direct · Créateurs découverts : 0 · touchés : 0 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 - **Cadence déclarée (registre)** : quotidienne (watch), en dernier du pipeline 31 - **Cadence déclarée (registre)** : quotidienne (watch), en dernier du pipeline
32 - **Cadence observée** (médiane sync_log) : — 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 - **Caps / budgets du module** : `MAX_NEW_PER_CREATOR` = 5 34 - **Caps / budgets du module** : `MAX_NEW_PER_CREATOR` = 5
35 - **Throttling** : 0.0 s entre requêtes (`request_delay`) 35 - **Throttling** : 0.0 s entre requêtes (`request_delay`)
36 36
37 ## Volumétrie & complétude 37 ## Volumétrie & complétude
38 38
39 - **Créateurs découverts par la source** (`doc.source`) : 0 · **fiches touchées** (`source_ids`) : 0 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 ## Erreurs connues & dépannage 42 ## Erreurs connues & dépannage
43 43
modified docs/connecteurs/instagram-profil.md +10 −10
@@ -1,6 +1,6 @@
1 # `instagram-profil` — connecteur enrichissement (palier 4) 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 **État : actif** · Famille : enrichissement (enrichment) · Backend : Scrapfly · Créateurs découverts : 0 · touchés : 0 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 ## Champs récupérés → schéma cible 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 | Colonne `accounts` | Contenu | Renseignée | Exemple réel | 30 | Colonne `accounts` | Contenu | Renseignée | Exemple réel |
31 |---|---|---|---| 31 |---|---|---|---|
32 | `handle` | Handle | 100 % | catherinepaiz | 32 | `handle` | Handle | 100 % | catherinepaiz |
33 | `url` | URL du profil | 100 % | https://www.instagram.com/catherinepaiz/ | 33 | `url` | URL du profil | 100 % | https://www.instagram.com/catherinepaiz/ |
34−| `followers` | Abonnés | 72 % | 7023891 | 34+| `followers` | Abonnés | 70 % | 7023547 |
35−| `verified` | Badge vérifié | 59 % | 1 | 35+| `verified` | Badge vérifié | 58 % | 1 |
36 | `confidence` | Confiance d'identité | 100 % | 0.85 | 36 | `confidence` | Confiance d'identité | 100 % | 0.85 |
37 | `signal` | Signal d'identité | 100 % | base_publique | 37 | `signal` | Signal d'identité | 100 % | base_publique |
38−| `metrics` | Métriques détaillées (JSON) | 59 % | {"following": 248, "posts": 667, "is_business": false} | 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-18T06:27:51Z | 39+| `last_checked` | Dernière vérification | 100 % | 2026-08-18T07:23:10Z |
40 40
41 ## Fréquence & budget 41 ## Fréquence & budget
42 42
43 - **Cadence déclarée (registre)** : hebdomadaire (gros créateurs) / mensuelle 43 - **Cadence déclarée (registre)** : hebdomadaire (gros créateurs) / mensuelle
44−- **Cadence observée** (médiane sync_log) : ≈ 87 min 44+- **Cadence observée** (médiane sync_log) : ≈ 2.1 h
45−- **Dernier passage** : 2026-08-17T23:23:45Z — 271 créateurs, 542 comptes, +0 / ~271, 0 erreur(s), 0 s 45+- **Dernier passage** : 2026-08-18T07:51:39Z — 274 créateurs, 698 comptes, +0 / ~274, 0 erreur(s), 1724 s
46 - **Throttling** : 1.5 s entre requêtes (`request_delay`) 46 - **Throttling** : 1.5 s entre requêtes (`request_delay`)
47 47
48 ## Volumétrie & complétude 48 ## Volumétrie & complétude
49 49
50 - **Créateurs découverts par la source** (`doc.source`) : 0 · **fiches touchées** (`source_ids`) : 0 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 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)** : 3, dont 0 avec erreurs 52+- **Runs journalisés (60 derniers)** : 4, dont 0 avec erreurs
53 53
54 ## Erreurs connues & dépannage 54 ## Erreurs connues & dépannage
55 55
modified docs/connecteurs/link-in-bio.md +4 −4
@@ -1,6 +1,6 @@
1 # `link-in-bio` — connecteur enrichissement (palier 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 **État : actif** · Famille : enrichissement (enrichment) · Backend : Firecrawl · Créateurs découverts : 0 · touchés : 0 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 ## Fréquence & budget 29 ## Fréquence & budget
30 30
31 - **Cadence déclarée (registre)** : mensuelle 31 - **Cadence déclarée (registre)** : mensuelle
32−- **Cadence observée** (médiane sync_log) : — 32+- **Cadence observée** (médiane sync_log) : ≈ 4.4 h
33−- **Dernier passage** : 2026-08-18T06:20:19Z — 74 créateurs, 288 comptes, +0 / ~74, 0 erreur(s), 114 s 33+- **Dernier passage** : 2026-08-18T09:57:40Z — 78 créateurs, 302 comptes, +0 / ~78, 0 erreur(s), 116 s
34 - **Throttling** : 1.2 s entre requêtes (`request_delay`) 34 - **Throttling** : 1.2 s entre requêtes (`request_delay`)
35 35
36 ## Volumétrie & complétude 36 ## Volumétrie & complétude
37 37
38 - **Créateurs découverts par la source** (`doc.source`) : 0 · **fiches touchées** (`source_ids`) : 0 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 ## Erreurs connues & dépannage 41 ## Erreurs connues & dépannage
42 42
modified docs/connecteurs/listes-medias.md +6 −6
@@ -1,6 +1,6 @@
1 # `listes-medias` — connecteur decouverte (palier 3) 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 **État : actif** · Famille : decouverte (discovery) · Backend : direct · Créateurs découverts : 310 · touchés : 310 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 | Champ du doc créateur | Contenu | Renseigné | Exemple réel | 29 | Champ du doc créateur | Contenu | Renseigné | Exemple réel |
30 |---|---|---|---| 30 |---|---|---|---|
31 | `display_name` | Nom public | 100 % | Enola Bédard | 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 | `region` | Région | 49 % | — | 33 | `region` | Région | 49 % | — |
34 | `city` | Ville | 49 % | — | 34 | `city` | Ville | 49 % | — |
35 | `niches` | Niches (liste) | 100 % | danse, lifestyle | 35 | `niches` | Niches (liste) | 100 % | danse, lifestyle |
@@ -37,9 +37,9 @@ Source de **découverte** : produit des fiches `Creator` (tables `creators` + `a
37 | `creator_type` | Type de créateur | 100 % | createur-tiktok | 37 | `creator_type` | Type de créateur | 100 % | createur-tiktok |
38 | `primary_platform` | Plateforme principale | 100 % | tiktok | 38 | `primary_platform` | Plateforme principale | 100 % | tiktok |
39 | `platforms` | Comptes sociaux (liste) | 100 % | tiktok:@enola.bedard, youtube:@enola.bedard, instagram:@enola.bedard | 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… | 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 % | 24500000 | 41+| `total_reach` | Portée totale (abonnés cumulés) | 90 % | 25307536 |
42−| `link_in_bio_url` | Lien-en-bio | 23 % | — | 42+| `link_in_bio_url` | Lien-en-bio | 24 % | — |
43 43
44 ## Fréquence & budget 44 ## Fréquence & budget
45 45
@@ -51,7 +51,7 @@ Source de **découverte** : produit des fiches `Creator` (tables `creators` + `a
51 ## Volumétrie & complétude 51 ## Volumétrie & complétude
52 52
53 - **Créateurs découverts par la source** (`doc.source`) : 310 · **fiches touchées** (`source_ids`) : 310 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 - **Runs journalisés (60 derniers)** : 11, dont 0 avec erreurs 55 - **Runs journalisés (60 derniers)** : 11, dont 0 avec erreurs
56 56
57 ## Erreurs connues & dépannage 57 ## Erreurs connues & dépannage
modified docs/connecteurs/onlyqueb.md +4 −4
@@ -1,6 +1,6 @@
1 # `onlyqueb` — connecteur decouverte (palier 3) 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 **État : actif** · Famille : decouverte (discovery) · Backend : direct · Créateurs découverts : 253 · touchés : 253 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 | `primary_platform` | Plateforme principale | 100 % | instagram | 39 | `primary_platform` | Plateforme principale | 100 % | instagram |
40 | `platforms` | Comptes sociaux (liste) | 100 % | instagram:@jadelavoie, x:@missjadelavoie, onlyfans:@misslavoie | 40 | `platforms` | Comptes sociaux (liste) | 100 % | instagram:@jadelavoie, x:@missjadelavoie, onlyfans:@misslavoie |
41 | `avatar_url` | Avatar | 100 % | https://storage.googleapis.com/onlyqueb/MQZBiXsbMQa31zxj9P_mabNxNITFBxa6 | 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 | `link_in_bio_url` | Lien-en-bio | 2 % | — | 43 | `link_in_bio_url` | Lien-en-bio | 2 % | — |
44 44
45 Cible d'**enrichissement** : colonnes de la table `accounts` pour la/les plateforme(s) `onlyfans` (248 comptes en BD, confiance moyenne 0.90). 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 ## Volumétrie & complétude 65 ## Volumétrie & complétude
66 66
67 - **Créateurs découverts par la source** (`doc.source`) : 253 · **fiches touchées** (`source_ids`) : 253 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 % 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-18T06:27:32Z 69+- **Comptes `onlyfans` en BD** : 248 — abonnés 0 % · métriques 0 % · dernière vérification 2026-08-18T09:57:40Z
70 - **Runs journalisés (60 derniers)** : 7, dont 0 avec erreurs 70 - **Runs journalisés (60 derniers)** : 7, dont 0 avec erreurs
71 71
72 ## Erreurs connues & dépannage 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 # `podcastindex` — connecteur enrichissement (palier 2) 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 **État : prêt — clés requises** · Famille : enrichissement (enrichment) · Backend : direct · Créateurs découverts : 0 · touchés : 0 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 ## Champs récupérés → schéma cible 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 | Colonne `accounts` | Contenu | Renseignée | Exemple réel | 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 ## Volumétrie & complétude 47 ## Volumétrie & complétude
48 48
49 - **Créateurs découverts par la source** (`doc.source`) : 0 · **fiches touchées** (`source_ids`) : 0 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 - **Runs journalisés (60 derniers)** : 0, dont 0 avec erreurs 51 - **Runs journalisés (60 derniers)** : 0, dont 0 avec erreurs
52 52
53 ## Erreurs connues & dépannage 53 ## Erreurs connues & dépannage
modified docs/connecteurs/tiktok-profil.md +9 −9
@@ -1,6 +1,6 @@
1 # `tiktok-profil` — connecteur enrichissement (palier 4) 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 **État : actif** · Famille : enrichissement (enrichment) · Backend : Scrapfly · Créateurs découverts : 0 · touchés : 0 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 ## Champs récupérés → schéma cible 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 | Colonne `accounts` | Contenu | Renseignée | Exemple réel | 30 | Colonne `accounts` | Contenu | Renseignée | Exemple réel |
31 |---|---|---|---| 31 |---|---|---|---|
32 | `handle` | Handle | 100 % | enola.bedard | 32 | `handle` | Handle | 100 % | enola.bedard |
33 | `url` | URL du profil | 100 % | https://www.tiktok.com/@enola.bedard | 33 | `url` | URL du profil | 100 % | https://www.tiktok.com/@enola.bedard |
34−| `followers` | Abonnés | 53 % | 17000000 | 34+| `followers` | Abonnés | 65 % | 17100000 |
35−| `verified` | Badge vérifié | 35 % | 1 | 35+| `verified` | Badge vérifié | 55 % | 1 |
36 | `confidence` | Confiance d'identité | 100 % | 0.85 | 36 | `confidence` | Confiance d'identité | 100 % | 0.85 |
37 | `signal` | Signal d'identité | 100 % | base_publique | 37 | `signal` | Signal d'identité | 100 % | base_publique |
38−| `metrics` | Métriques détaillées (JSON) | 35 % | {"following": 813, "likes": 438100000, "videos": 2847} | 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-18T06:27:51Z | 39+| `last_checked` | Dernière vérification | 100 % | 2026-08-18T10:07:33Z |
40 40
41 ## Fréquence & budget 41 ## Fréquence & budget
42 42
43 - **Cadence déclarée (registre)** : hebdomadaire (gros créateurs) / mensuelle 43 - **Cadence déclarée (registre)** : hebdomadaire (gros créateurs) / mensuelle
44 - **Cadence observée** (médiane sync_log) : — 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 - **Throttling** : 1.5 s entre requêtes (`request_delay`) 46 - **Throttling** : 1.5 s entre requêtes (`request_delay`)
47 47
48 ## Volumétrie & complétude 48 ## Volumétrie & complétude
49 49
50 - **Créateurs découverts par la source** (`doc.source`) : 0 · **fiches touchées** (`source_ids`) : 0 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 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)** : 1, dont 0 avec erreurs 52+- **Runs journalisés (60 derniers)** : 2, dont 0 avec erreurs
53 53
54 ## Erreurs connues & dépannage 54 ## Erreurs connues & dépannage
55 55
modified docs/connecteurs/twitch.md +22 −21
@@ -1,55 +1,55 @@
1 # `twitch` — connecteur enrichissement (palier 2) 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 **État : actif** · Famille : enrichissement (enrichment) · Backend : direct · Créateurs découverts : 0 · touchés : 0 5 **État : actif** · Famille : enrichissement (enrichment) · Backend : direct · Créateurs découverts : 0 · touchés : 0
6 6
7 ## Description de la source 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 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) (colonne `accounts.signal` / `confidence`) 12+- **Signal d'identité** : api_officielle (0.95) via Helix ; la voie GQL publique ne touche pas au score (colonne `accounts.signal` / `confidence`)
13 - **Module** : `creaka/connectors/twitch.py` — classe `TwitchConnector` (`kind = "enrichment"`) 13 - **Module** : `creaka/connectors/twitch.py` — classe `TwitchConnector` (`kind = "enrichment"`)
14 14
15 ## Accès 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 - **Type d'accès (code)** : API JSON 18 - **Type d'accès (code)** : API JSON
19 - **Endpoint de base** : https://id.twitch.tv/oauth2/token 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 - **Pagination** : réponse unique (pas de pagination) 21 - **Pagination** : réponse unique (pas de pagination)
22 - **Backend anti-bot / rendu** : requests direct (session UA CreaKaBot, throttling poli) 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 - **Authentification** : clé(s) API requise(s) — voir statut/registre 24 - **Authentification** : clé(s) API requise(s) — voir statut/registre
25 25
26 ## Champs récupérés → schéma cible 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 | Colonne `accounts` | Contenu | Renseignée | Exemple réel | 30 | Colonne `accounts` | Contenu | Renseignée | Exemple réel |
31 |---|---|---|---| 31 |---|---|---|---|
32−| `handle` | Handle | 100 % | heyraion | 32+| `handle` | Handle | 100 % | xqc |
33−| `url` | URL du profil | 100 % | https://www.twitch.tv/heyraion | 33+| `url` | URL du profil | 100 % | https://www.twitch.tv/xqc |
34−| `followers` | Abonnés | 15 % | 37900 | 34+| `followers` | Abonnés | 75 % | 12535964 |
35−| `verified` | Badge vérifié | 0 % | — | 35+| `verified` | Badge vérifié | 39 % | 1 |
36 | `confidence` | Confiance d'identité | 100 % | 0.85 | 36 | `confidence` | Confiance d'identité | 100 % | 0.85 |
37−| `signal` | Signal d'identité | 100 % | listes_medias | 37+| `signal` | Signal d'identité | 100 % | base_publique |
38−| `metrics` | Métriques détaillées (JSON) | 0 % | — | 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-18T06:23:00Z | 39+| `last_checked` | Dernière vérification | 100 % | 2026-08-18T10:07:33Z |
40 40
41 ## Fréquence & budget 41 ## Fréquence & budget
42 42
43 - **Cadence déclarée (registre)** : hebdomadaire 43 - **Cadence déclarée (registre)** : hebdomadaire
44 - **Cadence observée** (médiane sync_log) : — 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 45+- **Dernier passage** : 2026-08-18T09:50:40Z — 24 créateurs, 90 comptes, +0 / ~24, 0 erreur(s), 1 s
46−- **Throttling** : 0.5 s entre requêtes (`request_delay`) 46+- **Throttling** : 1.0 s entre requêtes (`request_delay`)
47 47
48 ## Volumétrie & complétude 48 ## Volumétrie & complétude
49 49
50 - **Créateurs découverts par la source** (`doc.source`) : 0 · **fiches touchées** (`source_ids`) : 0 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 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)** : 1, dont 0 avec erreurs 52+- **Runs journalisés (60 derniers)** : 2, dont 0 avec erreurs
53 53
54 ## Erreurs connues & dépannage 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 ## Licence, attribution & conditions 62 ## Licence, attribution & conditions
63 63
64−- **Cadre d'accès (registre)** : API officielle Helix (client_credentials) — sauté sans clés 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−- **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`). 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). 66 - Retrait sur demande : contact@spboucher.ai (opt-out honoré à la prochaine ingestion).
67 67
68 ## Historique 68 ## Historique
69 69
70+- 2026-08-18 — date mentionnée au registre (voir notes/statut).
70 - 2026-08-18 — plus ancien passage journalisé dans `sync_log` (fenêtre des 60 derniers). 71 - 2026-08-18 — plus ancien passage journalisé dans `sync_log` (fenêtre des 60 derniers).
71 - 2026-08-18 — vague d'enrichissement : standardisation de la documentation des connecteurs (fiche générée par `scripts/gen_connector_docs.py`). 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
Large diff (40 lines) is not loaded by default to keep the page fast.Load diff
added docs/connecteurs/wikipedia-qc.md +73 −0
Large diff (73 lines) is not loaded by default to keep the page fast.Load diff
modified docs/connecteurs/x-profil.md +10 −9
Large diff (52 lines) is not loaded by default to keep the page fast.Load diff
modified docs/connecteurs/youtube-recherche.md +8 −8
Large diff (44 lines) is not loaded by default to keep the page fast.Load diff
modified docs/connecteurs/youtube.md +21 −19
Large diff (86 lines) is not loaded by default to keep the page fast.Load diff
modified scripts/gen_connector_docs.py +1 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added tests/test_vague3.py +183 −0
Large diff (184 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme