SPB Git forge

spb/crea-ka

Public

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

52commits 1branches 0releases
11.3 MBsize
maindefault branch
19 days agolast push
Python 73.6% HTML 13.2% TypeScript 6% JavaScript 4.5% CSS 1.7% Dockerfile 0.6%

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 1 mo ago (Aug 18, 2026) parent 1b5c1b3

25 changed files +1,312 −196

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