Créa·Ka — annuaire public cross-plateforme des créateurs de contenu québécois (crea-ka.com)
Python 73.6%
HTML 13.2%
TypeScript 6%
JavaScript 4.5%
CSS 1.7%
Dockerfile 0.6%
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File: creaka/connectors/wikipedia_qc.py4# Desc: Connecteur DÉCOUVERTE — créateurs de contenu québécois recensés dans5# les CATÉGORIES de Wikipédia (fr + en), handles sociaux CURÉS tirés6# de l'item Wikidata de chaque article. Mode d'accès : API MediaWiki7# 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.1112Complète wikidata-qc (requêtes SPARQL par occupation+lieu) : les CATÉGORIES13é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 pas15toujours l'occupation ou le lieu bien remplis — mais dont l'article, lui, est16catégorisé par la communauté. Chaîne : membres de catégorie → intro d'article17(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).2021Prudence (§15) : année de naissance publique (P569) → is_minor levé22automatiquement ; personnes décédées (P570) exclues (annuaire de créateurs23actifs) ; fiche créée SEULEMENT si ≥1 lien social curé existe.24"""25from __future__ import annotations2627import sys28from datetime import datetime, timezone29from urllib.parse import urlparse3031from ..identity import account32from ..normalize import slugify33from ..schema import Creator34from .base import BaseConnector35from .youtube_recherche import is_quebec3637WIKI_API = "https://{lang}.wikipedia.org/w/api.php"38WIKIDATA_API = "https://www.wikidata.org/w/api.php"3940# (langue, catégorie, marqueur QC requis dans l'intro ?, niche, type)41CATEGORIES: 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 # vague 4 — catégories supplémentaires vérifiées (2026-08-18)56 ("fr", "Catégorie:Blogueuse québécoise", False, "actualite-opinion",57 "createur-ecrit"),58 ("en", "Category:Canadian Internet celebrities", True, "lifestyle",59 "influenceur"),60 ("en", "Category:Canadian women podcasters", True, "actualite-opinion",61 "podcasteur"),62 ("en", "Category:Canadian video bloggers", True, "lifestyle", "youtubeur"),63 ("en", "Category:Canadian gaming YouTubers", True, "gaming", "youtubeur"),64]6566# propriétés Wikidata → plateformes §6.2 (handles curés par la communauté)67_PROP_PLATFORM = {68 "P2003": "instagram", "P7085": "tiktok", "P2002": "x",69 "P5797": "twitch", "P2013": "facebook",70}71_YT_CHANNEL_PROP = "P2397" # ID de chaîne YouTube (UC…) → URL directe72_WEBSITE_PROP = "P856" # site officiel → compte site-web73_BIRTH_PROP, _DEATH_PROP = "P569", "P570"747576def claim_values(claims: dict, prop: str) -> list:77 """Valeurs d'une propriété Wikidata (mainsnak.datavalue.value)."""78 out = []79 for st in claims.get(prop) or []:80 dv = (st.get("mainsnak") or {}).get("datavalue")81 if dv is not None and dv.get("value") is not None:82 out.append(dv["value"])83 return out848586def accounts_from_claims(claims: dict) -> list:87 """Claims Wikidata → comptes PlatformAccount (signal base_publique)."""88 accounts = []89 for prop, platform in _PROP_PLATFORM.items():90 for handle in claim_values(claims, prop):91 if isinstance(handle, str) and handle.strip():92 accounts.append(account(platform, handle.strip(),93 "base_publique"))94 for cid in claim_values(claims, _YT_CHANNEL_PROP):95 if isinstance(cid, str) and cid.strip():96 accounts.append(account(97 "youtube", cid.strip(), "base_publique",98 url=f"https://www.youtube.com/channel/{cid.strip()}"))99 for url in claim_values(claims, _WEBSITE_PROP):100 if isinstance(url, str) and url.startswith("http"):101 domain = urlparse(url).netloc.lower().removeprefix("www.")102 if domain:103 accounts.append(account("site-web", domain, "base_publique",104 url=url))105 return accounts106107108def birth_year(claims: dict) -> int | None:109 for v in claim_values(claims, _BIRTH_PROP):110 t = v.get("time") if isinstance(v, dict) else None111 if t and len(t) >= 5:112 try:113 return int(t[1:5])114 except ValueError:115 continue116 return None117118119def is_deceased(claims: dict) -> bool:120 return bool(claims.get(_DEATH_PROP))121122123class WikipediaQcConnector(BaseConnector):124 source_id = "wikipedia-qc"125 kind = "discovery"126 request_delay = 1.0127 timeout = 45128 batch_extracts = 20 # exlimit max de prop=extracts avec exintro129 batch_entities = 50 # wbgetentities max sans droit bot130131 def __init__(self) -> None:132 super().__init__()133 self.errors = 0134135 def _members(self, lang: str, category: str) -> list[dict]:136 """Tous les articles (ns=0) d'une catégorie, avec pagination."""137 members, cont = [], {}138 while True:139 params = {"action": "query", "list": "categorymembers",140 "cmtitle": category, "cmtype": "page",141 "cmlimit": "500", "format": "json", **cont}142 data = self.get(WIKI_API.format(lang=lang), params=params).json()143 members += [m for m in144 data.get("query", {}).get("categorymembers", [])145 if m.get("ns") == 0]146 cont = data.get("continue") or {}147 if not cont:148 return members149150 def _pages(self, lang: str, pageids: list[int]) -> list[dict]:151 """Intro (2 phrases) + item Wikidata + image, par lots de 20."""152 pages: list[dict] = []153 for i in range(0, len(pageids), self.batch_extracts):154 batch = pageids[i:i + self.batch_extracts]155 data = self.get(WIKI_API.format(lang=lang), params={156 "action": "query", "pageids": "|".join(map(str, batch)),157 "prop": "extracts|pageprops|pageimages",158 "exintro": "1", "explaintext": "1", "exsentences": "3",159 "ppprop": "wikibase_item",160 "piprop": "thumbnail", "pithumbsize": "400",161 "format": "json"}).json()162 pages += list(data.get("query", {}).get("pages", {}).values())163 return pages164165 def _entities(self, qids: list[str]) -> dict:166 """Claims Wikidata par QID, par lots de 50."""167 entities: dict = {}168 for i in range(0, len(qids), self.batch_entities):169 batch = qids[i:i + self.batch_entities]170 data = self.get(WIKIDATA_API, params={171 "action": "wbgetentities", "ids": "|".join(batch),172 "props": "claims", "format": "json"}).json()173 entities.update(data.get("entities") or {})174 return entities175176 def fetch(self) -> list[Creator]:177 year_now = datetime.now(timezone.utc).year178 # QID → fiche en construction (une personne peut être dans 2+ catégories)179 drafts: dict[str, dict] = {}180 for lang, category, need_qc, niche, ctype in CATEGORIES:181 try:182 members = self._members(lang, category)183 pages = self._pages(lang, [m["pageid"] for m in members])184 except Exception as exc:185 # une catégorie en échec ne bloque pas les autres — jamais en186 # silence (§16/§18) : la couverture chuterait sans trace187 self.errors += 1188 print(f"[crea-ka] ⚠ wikipedia-qc : {lang}:{category} en "189 f"échec : {exc}", file=sys.stderr)190 continue191 for p in pages:192 qid = (p.get("pageprops") or {}).get("wikibase_item")193 title = (p.get("title") or "").strip()194 extract = (p.get("extract") or "").strip()195 if not qid or not title:196 continue197 # attribution prudente (§7/§15) : catégorie pan-canadienne →198 # marqueur québécois OBLIGATOIRE dans l'intro de l'article199 if need_qc and not is_quebec(extract):200 continue201 d = drafts.setdefault(qid, {202 "name": title.split(" (")[0], "bio": "", "avatar": None,203 "niches": set(), "types": [], "langs": set()})204 d["niches"].add(niche)205 d["types"].append(ctype)206 d["langs"].add(lang)207 if len(extract) > len(d["bio"]):208 d["bio"] = extract209 thumb = ((p.get("thumbnail") or {}).get("source"))210 if thumb and not d["avatar"]:211 d["avatar"] = thumb212 if not drafts:213 return []214 try:215 entities = self._entities(sorted(drafts))216 except Exception as exc:217 self.errors += 1218 print(f"[crea-ka] ⚠ wikipedia-qc : wbgetentities en échec : {exc}",219 file=sys.stderr)220 return []221 creators: list[Creator] = []222 for qid, d in drafts.items():223 claims = (entities.get(qid) or {}).get("claims") or {}224 if is_deceased(claims):225 continue # annuaire de créateurs actifs226 accounts = accounts_from_claims(claims)227 if not accounts:228 continue # un annuaire de LIENS : pas de fiche sans lien229 birth = birth_year(claims)230 is_minor = bool(birth and (year_now - birth) < 18)231 creators.append(Creator(232 source=self.source_id,233 external_id=qid,234 display_name=d["name"],235 bio=d["bio"][:1200],236 niches=sorted(d["niches"]) or ["autre"],237 creator_type=d["types"][0],238 avatar_url=d["avatar"],239 is_minor=is_minor,240 platforms=accounts,241 notes="Article Wikipédia (catégories de créateurs québécois).",242 source_ids=[f"{self.source_id}:{qid}"],243 ))244 # tri stable pour des passages reproductibles245 return sorted(creators, key=lambda c: slugify(c.display_name))246