# ============================================================================== # Author: Simon-Pierre Boucher # File: creaka/connectors/wikidata_qc.py # Desc: Connecteur DÉCOUVERTE — créateurs québécois recensés dans Wikidata # (personnes nées ou résidant au Québec, occupation créateur, avec # handles sociaux curés P2003/P7085/P2397/P5797/P2002). Mode d'accès : # API SPARQL publique de Wikidata (gratuite, conforme). Palier 3 (§9). # ============================================================================== """Découverte via Wikidata : au-delà des listes médias. Wikidata référence les créateurs notables (humoristes, youtubeurs, vidéastes web, streamers, podcasteurs, influenceurs) avec leurs comptes sociaux CURÉS par la communauté → signal `base_publique` (0.85). Deux requêtes (lieu de naissance / lieu de résidence au Québec) fusionnées. Prudence mineurs (§15) : l'année de naissance publique permet de lever `is_minor` automatiquement → régime restreint appliqué par ethics.py. """ from __future__ import annotations import sys from datetime import datetime, timezone from ..identity import account from ..normalize import slugify from ..schema import Creator from .base import BaseConnector SPARQL_URL = "https://query.wikidata.org/sparql" # occupations « créateur de contenu » (ids Wikidata) # Q17125263 vidéaste web · Q94791573 tiktokeur · Q50279140 streamer # Q15077007 podcasteur · Q2906862 influenceur · Q245068 humoriste _OCCS = "wd:Q17125263 wd:Q94791573 wd:Q50279140 wd:Q15077007 wd:Q2906862 wd:Q245068" _OCC_NICHE = { "Q245068": "humour", "Q50279140": "gaming", "Q15077007": "actualite-opinion", "Q17125263": "lifestyle", "Q94791573": "lifestyle", "Q2906862": "lifestyle", } _QUERY = """ SELECT DISTINCT ?person ?personLabel ?occ ?ig ?tk ?yt ?tw ?x (YEAR(?dob) AS ?birthYear) WHERE { VALUES ?occ { %(occs)s } ?person wdt:P106 ?occ . ?person wdt:%(place_prop)s ?place . ?place wdt:P131* wd:Q176 . hint:Prior hint:gearing "forward" . OPTIONAL { ?person wdt:P2003 ?ig } OPTIONAL { ?person wdt:P7085 ?tk } OPTIONAL { ?person wdt:P2397 ?yt } OPTIONAL { ?person wdt:P5797 ?tw } OPTIONAL { ?person wdt:P2002 ?x } OPTIONAL { ?person wdt:P569 ?dob } FILTER(BOUND(?ig) || BOUND(?tk) || BOUND(?yt) || BOUND(?tw) || BOUND(?x)) SERVICE wikibase:label { bd:serviceParam wikibase:language "fr,en". } } LIMIT 3000 """ class WikidataQcConnector(BaseConnector): source_id = "wikidata-qc" kind = "discovery" request_delay = 2.0 timeout = 90 def _query(self, place_prop: str) -> list[dict]: resp = self.get(SPARQL_URL, params={"query": _QUERY % {"occs": _OCCS, "place_prop": place_prop}}, headers={"Accept": "application/sparql-results+json"}) return resp.json().get("results", {}).get("bindings", []) def fetch(self) -> list[Creator]: rows: list[dict] = [] self.errors = 0 # comptabilisé dans sync_log.errors par le pipeline (§16) for prop in ("P19", "P551"): # naissance, résidence try: rows.extend(self._query(prop)) except Exception as exc: # une branche en échec ne bloque pas l'autre — mais JAMAIS en # silence : la couverture chute si une requête SPARQL casse self.errors += 1 print(f"[crea-ka] ⚠ wikidata-qc : requête SPARQL {prop} en " f"échec : {exc}", file=sys.stderr) by_person: dict[str, dict] = {} for r in rows: def val(key): return (r.get(key) or {}).get("value") pid = val("person") if not pid: continue entry = by_person.setdefault( pid, {"name": val("personLabel"), "occs": set(), "birth": None, "handles": {}}) entry["occs"].add((val("occ") or "").rsplit("/", 1)[-1]) if val("birthYear"): entry["birth"] = int(val("birthYear")) for key, platform in (("ig", "instagram"), ("tk", "tiktok"), ("tw", "twitch"), ("x", "x")): if val(key): entry["handles"][platform] = val(key) # P2397 = ID de chaîne YouTube (UC…) → URL directe, pas un handle @ if val("yt"): entry["handles"]["youtube_channel"] = val("yt") year_now = datetime.now(timezone.utc).year creators: list[Creator] = [] for pid, e in by_person.items(): name = (e["name"] or "").strip() qid = pid.rsplit("/", 1)[-1] if not name or name == qid: continue # entité sans libellé humain accounts = [] for platform, handle in e["handles"].items(): if platform == "youtube_channel": accounts.append(account( "youtube", handle, "base_publique", url=f"https://www.youtube.com/channel/{handle}")) else: accounts.append(account(platform, handle, "base_publique")) if not accounts: continue is_minor = bool(e["birth"] and (year_now - e["birth"]) < 18) niches = sorted({_OCC_NICHE.get(o, "autre") for o in e["occs"]}) or ["autre"] creators.append(Creator( source=self.source_id, external_id=qid, display_name=name, niches=niches, is_minor=is_minor, platforms=accounts, source_ids=[f"{self.source_id}:{qid}"], )) # tri stable pour des passages reproductibles return sorted(creators, key=lambda c: slugify(c.display_name))