# ============================================================================== # Author: Simon-Pierre Boucher # File: creaka/connectors/wikipedia_qc.py # Desc: 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. # ============================================================================== """Découverte via les catégories Wikipédia de créateurs québécois. Complète wikidata-qc (requêtes SPARQL par occupation+lieu) : les CATÉGORIES éditoriales de Wikipédia (« Vidéaste web canadien », « Humoriste québécois », « Canadian YouTubers »…) recensent des créateurs dont l'item Wikidata n'a pas toujours l'occupation ou le lieu bien remplis — mais dont l'article, lui, est catégorisé par la communauté. Chaîne : membres de catégorie → intro d'article (marqueur QUÉBÉCOIS OBLIGATOIRE pour les catégories pan-canadiennes, §7/§15) → item Wikidata → handles sociaux curés (P2003/P7085/P2397/P2002/P5797/P2013) + site officiel (P856). Signal `base_publique` (0.85). Prudence (§15) : année de naissance publique (P569) → is_minor levé automatiquement ; personnes décédées (P570) exclues (annuaire de créateurs actifs) ; fiche créée SEULEMENT si ≥1 lien social curé existe. """ from __future__ import annotations import sys from datetime import datetime, timezone from urllib.parse import urlparse from ..identity import account from ..normalize import slugify from ..schema import Creator from .base import BaseConnector from .youtube_recherche import is_quebec WIKI_API = "https://{lang}.wikipedia.org/w/api.php" WIKIDATA_API = "https://www.wikidata.org/w/api.php" # (langue, catégorie, marqueur QC requis dans l'intro ?, niche, type) CATEGORIES: list[tuple[str, str, bool, str, str]] = [ ("fr", "Catégorie:Vidéaste web canadien", True, "lifestyle", "youtubeur"), ("fr", "Catégorie:Humoriste québécois", False, "humour", "humoriste"), ("fr", "Catégorie:Blogueur québécois", False, "actualite-opinion", "createur-ecrit"), ("fr", "Catégorie:Podcasteur", True, "actualite-opinion", "podcasteur"), ("fr", "Catégorie:Podcasteuse", True, "actualite-opinion", "podcasteur"), ("en", "Category:YouTubers from Quebec", False, "lifestyle", "youtubeur"), ("en", "Category:Canadian YouTubers", True, "lifestyle", "youtubeur"), ("en", "Category:Canadian podcasters", True, "actualite-opinion", "podcasteur"), ("en", "Category:Canadian Twitch (service) streamers", True, "gaming", "streamer"), ("en", "Category:Canadian TikTokers", True, "lifestyle", "createur-tiktok"), # vague 4 — catégories supplémentaires vérifiées (2026-08-18) ("fr", "Catégorie:Blogueuse québécoise", False, "actualite-opinion", "createur-ecrit"), ("en", "Category:Canadian Internet celebrities", True, "lifestyle", "influenceur"), ("en", "Category:Canadian women podcasters", True, "actualite-opinion", "podcasteur"), ("en", "Category:Canadian video bloggers", True, "lifestyle", "youtubeur"), ("en", "Category:Canadian gaming YouTubers", True, "gaming", "youtubeur"), ] # propriétés Wikidata → plateformes §6.2 (handles curés par la communauté) _PROP_PLATFORM = { "P2003": "instagram", "P7085": "tiktok", "P2002": "x", "P5797": "twitch", "P2013": "facebook", } _YT_CHANNEL_PROP = "P2397" # ID de chaîne YouTube (UC…) → URL directe _WEBSITE_PROP = "P856" # site officiel → compte site-web _BIRTH_PROP, _DEATH_PROP = "P569", "P570" def claim_values(claims: dict, prop: str) -> list: """Valeurs d'une propriété Wikidata (mainsnak.datavalue.value).""" out = [] for st in claims.get(prop) or []: dv = (st.get("mainsnak") or {}).get("datavalue") if dv is not None and dv.get("value") is not None: out.append(dv["value"]) return out def accounts_from_claims(claims: dict) -> list: """Claims Wikidata → comptes PlatformAccount (signal base_publique).""" accounts = [] for prop, platform in _PROP_PLATFORM.items(): for handle in claim_values(claims, prop): if isinstance(handle, str) and handle.strip(): accounts.append(account(platform, handle.strip(), "base_publique")) for cid in claim_values(claims, _YT_CHANNEL_PROP): if isinstance(cid, str) and cid.strip(): accounts.append(account( "youtube", cid.strip(), "base_publique", url=f"https://www.youtube.com/channel/{cid.strip()}")) for url in claim_values(claims, _WEBSITE_PROP): if isinstance(url, str) and url.startswith("http"): domain = urlparse(url).netloc.lower().removeprefix("www.") if domain: accounts.append(account("site-web", domain, "base_publique", url=url)) return accounts def birth_year(claims: dict) -> int | None: for v in claim_values(claims, _BIRTH_PROP): t = v.get("time") if isinstance(v, dict) else None if t and len(t) >= 5: try: return int(t[1:5]) except ValueError: continue return None def is_deceased(claims: dict) -> bool: return bool(claims.get(_DEATH_PROP)) class WikipediaQcConnector(BaseConnector): source_id = "wikipedia-qc" kind = "discovery" request_delay = 1.0 timeout = 45 batch_extracts = 20 # exlimit max de prop=extracts avec exintro batch_entities = 50 # wbgetentities max sans droit bot def __init__(self) -> None: super().__init__() self.errors = 0 def _members(self, lang: str, category: str) -> list[dict]: """Tous les articles (ns=0) d'une catégorie, avec pagination.""" members, cont = [], {} while True: params = {"action": "query", "list": "categorymembers", "cmtitle": category, "cmtype": "page", "cmlimit": "500", "format": "json", **cont} data = self.get(WIKI_API.format(lang=lang), params=params).json() members += [m for m in data.get("query", {}).get("categorymembers", []) if m.get("ns") == 0] cont = data.get("continue") or {} if not cont: return members def _pages(self, lang: str, pageids: list[int]) -> list[dict]: """Intro (2 phrases) + item Wikidata + image, par lots de 20.""" pages: list[dict] = [] for i in range(0, len(pageids), self.batch_extracts): batch = pageids[i:i + self.batch_extracts] data = self.get(WIKI_API.format(lang=lang), params={ "action": "query", "pageids": "|".join(map(str, batch)), "prop": "extracts|pageprops|pageimages", "exintro": "1", "explaintext": "1", "exsentences": "3", "ppprop": "wikibase_item", "piprop": "thumbnail", "pithumbsize": "400", "format": "json"}).json() pages += list(data.get("query", {}).get("pages", {}).values()) return pages def _entities(self, qids: list[str]) -> dict: """Claims Wikidata par QID, par lots de 50.""" entities: dict = {} for i in range(0, len(qids), self.batch_entities): batch = qids[i:i + self.batch_entities] data = self.get(WIKIDATA_API, params={ "action": "wbgetentities", "ids": "|".join(batch), "props": "claims", "format": "json"}).json() entities.update(data.get("entities") or {}) return entities def fetch(self) -> list[Creator]: year_now = datetime.now(timezone.utc).year # QID → fiche en construction (une personne peut être dans 2+ catégories) drafts: dict[str, dict] = {} for lang, category, need_qc, niche, ctype in CATEGORIES: try: members = self._members(lang, category) pages = self._pages(lang, [m["pageid"] for m in members]) except Exception as exc: # une catégorie en échec ne bloque pas les autres — jamais en # silence (§16/§18) : la couverture chuterait sans trace self.errors += 1 print(f"[crea-ka] ⚠ wikipedia-qc : {lang}:{category} en " f"échec : {exc}", file=sys.stderr) continue for p in pages: qid = (p.get("pageprops") or {}).get("wikibase_item") title = (p.get("title") or "").strip() extract = (p.get("extract") or "").strip() if not qid or not title: continue # attribution prudente (§7/§15) : catégorie pan-canadienne → # marqueur québécois OBLIGATOIRE dans l'intro de l'article if need_qc and not is_quebec(extract): continue d = drafts.setdefault(qid, { "name": title.split(" (")[0], "bio": "", "avatar": None, "niches": set(), "types": [], "langs": set()}) d["niches"].add(niche) d["types"].append(ctype) d["langs"].add(lang) if len(extract) > len(d["bio"]): d["bio"] = extract thumb = ((p.get("thumbnail") or {}).get("source")) if thumb and not d["avatar"]: d["avatar"] = thumb if not drafts: return [] try: entities = self._entities(sorted(drafts)) except Exception as exc: self.errors += 1 print(f"[crea-ka] ⚠ wikipedia-qc : wbgetentities en échec : {exc}", file=sys.stderr) return [] creators: list[Creator] = [] for qid, d in drafts.items(): claims = (entities.get(qid) or {}).get("claims") or {} if is_deceased(claims): continue # annuaire de créateurs actifs accounts = accounts_from_claims(claims) if not accounts: continue # un annuaire de LIENS : pas de fiche sans lien birth = birth_year(claims) is_minor = bool(birth and (year_now - birth) < 18) creators.append(Creator( source=self.source_id, external_id=qid, display_name=d["name"], bio=d["bio"][:1200], niches=sorted(d["niches"]) or ["autre"], creator_type=d["types"][0], avatar_url=d["avatar"], is_minor=is_minor, platforms=accounts, notes="Article Wikipédia (catégories de créateurs québécois).", source_ids=[f"{self.source_id}:{qid}"], )) # tri stable pour des passages reproductibles return sorted(creators, key=lambda c: slugify(c.display_name))