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%
11.1 KB · 246 lines python
Raw Blame History
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