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%

Périmètre QC : garde-fous snowball-ig + purge 4 605 fiches hors-Québec

La boule de neige IG (profils reliés) ramenait les vedettes mondiales
(cristiano, selenagomez, bellahadid…) et _find_existing exigeait une
confiance forte des deux côtés, créant des doublons orphelins. Correctifs :
- snowball : filtre is_quebec obligatoire + blocklist organismes/marques/
  médias/partis QC (villes, tourisme, ministères… ne sont pas des créateurs)
- _QC_MARKERS : « queb » nu matchait « QUEBra » (pt) et « jacQUEBrazil » →
  « quebec » complet ou « queb » en fin de mot (vague 4)
- db : rapprochement par compte identique (plateforme+handle) sans condition
  de confiance — fini les secondes fiches qui volaient le compte (691 cas)
- purge base : 4 439 snowball hors-QC, 95 organismes QC non-créateurs,
  1 182 orphelines/doublons → 12 445 créateurs propres
- tests/test_perimetre_qc.py : régressions vedettes, organismes, faux
  positifs de marqueur, anti-doublons

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 20, 2026) parent 9b0a685

4 changed files +168 −24

modified creaka/connectors/snowball.py +42 −10
@@ -3,10 +3,11 @@
3 3 # File: creaka/connectors/snowball.py
4 4 # Desc: Connecteur DÉCOUVERTE par boule de neige — exploite les profils
5 5 # reliés (`related_profiles`) captés par l'enrichissement Instagram
6 # (instagram-apify) pour créer de nouvelles fiches. C'est le levier
7 # d'échelle : chaque profil IG suggère ~10-20 comptes similaires →
8 # parcours en largeur qui fait croître le bassin de dizaines de milliers
9 # au fil des vagues quotidiennes. Aucune requête réseau (lit la base).
6 +# (instagram-apify) pour créer de nouvelles fiches. FILTRE QC
7 +# OBLIGATOIRE (marqueur québécois dans le nom/handle) : sans lui, la
8 +# proximité algorithmique d'Instagram ramène les vedettes mondiales
9 +# (constaté 2026-08-20 : cristiano, selenagomez, ferrari, nasa…).
10 +# Aucune requête réseau (lit la base).
10 11 # ==============================================================================
11 12 """Découverte par boule de neige sur les profils reliés Instagram.
12 13
@@ -14,15 +15,39 @@ Déclaré `enrichment` pour recevoir la liste des créateurs existants, mais il
14 15 RETOURNE de NOUVELLES fiches (le pipeline sync_source les ajoute par uid). On
15 16 lit `account.metrics["related_profiles"]` (posé par instagram-apify), on écarte
16 17 les handles déjà connus, et on crée une graine par nouveau compte suggéré.
17 Signal `mention` (0.60) : proximité algorithmique, à confirmer par
18 l'enrichissement — sous le seuil de publication tant que non vérifié (§12.1).
18 +
19 +GARDE-FOU QC (2026-08-20) : les suggestions d'Instagram débordent largement du
20 +Québec (stars mondiales, marques, comptes US/BR) — 3 767 fiches hors-périmètre
21 +ajoutées en un passage avant ce filtre. On n'émet donc QUE si le nom public ou
22 +le handle porte un marqueur québécois (`is_quebec`). Périmètre (§1-2) : Créa-Ka
23 +n'agrège que des créateurs QUÉBÉCOIS — mieux vaut manquer un profil QC sans
24 +marqueur que d'inonder l'annuaire de vedettes américaines (§3 travail de
25 +moine). Signal `mention` (0.60) : à confirmer par l'enrichissement (§12.1).
19 26 """
20 27 from __future__ import annotations
21 28
29 +import re
30 +
22 31 from ..identity import account
23 from ..normalize import canonical_url
32 +from ..normalize import canonical_url, strip_accents
24 33 from ..schema import Creator
25 34 from .base import BaseConnector
35 +from .youtube_recherche import is_quebec
36 +
37 +# Comptes QC qui ne sont PAS des créateurs (revue manuelle du 2026-08-20 :
38 +# 95 des 101 survivantes du filtre QC étaient des villes, offices de tourisme,
39 +# ministères, partis, commerces, festivals, médias) — on les bloque à la
40 +# source pour ne pas re-polluer l'annuaire à chaque vague.
41 +_NON_CREATEUR = re.compile(
42 + r"ville|tourisme|musee|festival|fondation|ymca|universite|uqam|"
43 + r"umontreal|ulaval|cegep|bibliothe|librairie|boutique|clinique|"
44 + r"auberge|hotel|ministere|gouv|transport|elections|loto|\bparti\b|"
45 + r"liberal|solidaire|conservateur|justepourrire|telequebec|magazine|"
46 + r"societe|association|federation")
47 +
48 +
49 +def _est_organisation(text: str) -> bool:
50 + return bool(_NON_CREATEUR.search(strip_accents(text or "").lower()))
26 51
27 52
28 53 class SnowballConnector(BaseConnector):
@@ -52,6 +77,15 @@ class SnowballConnector(BaseConnector):
52 77 continue
53 78 if handle in known or handle in emitted:
54 79 continue
80 + name = (rel.get("full_name") if isinstance(rel, dict)
81 + else None) or ""
82 + # périmètre QC obligatoire : marqueur québécois dans le nom
83 + # public ou le handle, sinon on n'émet PAS (§1-2) ; et on
84 + # écarte les organismes/marques/médias QC (pas des créateurs)
85 + if not is_quebec(f"{name} {handle}"):
86 + continue
87 + if _est_organisation(f"{name} {handle}"):
88 + continue
55 89 emitted.add(handle)
56 90 try:
57 91 acc = account("instagram", handle, "mention",
@@ -61,12 +95,10 @@ class SnowballConnector(BaseConnector):
61 95 else None)).finalize()
62 96 except Exception:
63 97 continue
64 name = (rel.get("full_name") if isinstance(rel, dict)
65 else None) or handle
66 98 new.append(Creator(
67 99 source=self.source_id,
68 100 external_id=f"instagram:{handle}",
69 display_name=name,
101 + display_name=name or handle,
70 102 platforms=[acc],
71 103 notes="découvert par boule de neige (profils reliés IG)",
72 104 ))
modified creaka/connectors/youtube_recherche.py +6 −2
@@ -35,9 +35,13 @@ _VER_RE = re.compile(r'"INNERTUBE_CONTEXT_CLIENT_VERSION":"([^"]+)"')
35 35
36 36 # marqueurs québécois (comparés SANS accents, en minuscules)
37 37 # ⚠ frontières de mot sur les toponymes courts/piégeux : « levis » matchait
38 # dans « teLEVISion », « estrie » dans « tapESTRIEs » (corrigé vague 3)
38 +# dans « teLEVISion », « estrie » dans « tapESTRIEs » (corrigé vague 3) ;
39 +# « queb » nu matchait « QUEBra » (portugais, tatiQUEBrabarraco) et
40 +# « jacQUEBrazil » (corrigé vague 4) — « quebec » complet n'importe où, ou
41 +# « queb » en fin de mot (onlyqueb, grainsduquebec restent couverts)
39 42 _QC_MARKERS = re.compile(
40 r"queb|montreal|\bmtl\b|saguenay|gaspesie|mauricie|\bestrie\b|outaouais|"
43 + r"quebec|queb(?![a-z])|montreal|\bmtl\b|saguenay|gaspesie|mauricie|"
44 + r"\bestrie\b|outaouais|"
41 45 r"abitibi|\blevis\b|sherbrooke|trois-rivieres|gatineau|\blaval\b|longueuil|"
42 46 r"rimouski|chicoutimi|drummondville|\bgranby\b|\bbeauce\b|charlevoix|"
43 47 r"lanaudiere|laurentides|monteregie|temiscamingue|\bjoliette\b|"
modified creaka/db.py +22 −12
@@ -27,8 +27,8 @@ from datetime import datetime, timedelta, timezone
27 27 from pathlib import Path
28 28
29 29 from . import ethics
30 from .dedup import merge_creators, should_merge
31 from .identity import STRONG_THRESHOLD, needs_review
30 +from .dedup import merge_creators
31 +from .identity import needs_review
32 32 from .normalize import slugify
33 33 from .schema import Creator, PlatformAccount, now_iso
34 34
@@ -183,14 +183,22 @@ def _write_creator(con: sqlite3.Connection, cid: str, cr: Creator,
183 183
184 184
185 185 def _find_existing(con: sqlite3.Connection, cr: Creator) -> str | None:
186 """Fiche existante partageant un compte FORT avec la fiche entrante (§12.2)."""
186 + """Fiche existante possédant déjà un des comptes entrants.
187 +
188 + Même plateforme + même handle = MÊME compte : la table accounts n'admet
189 + qu'un propriétaire par compte (UNIQUE platform,handle). Le rapprochement se
190 + fait donc sur l'identité exacte du compte, sans condition de confiance —
191 + exiger `confidence >= STRONG_THRESHOLD` des deux côtés (version d'avant le
192 + 2026-08-20) faisait rater les fiches à signal faible (mention 0.60, ex.
193 + snowball-ig) : sync_source créait alors une SECONDE fiche et l'upsert des
194 + comptes lui réassignait le compte, laissant l'originale orpheline
195 + (691 doublons constatés). Ce n'est PAS une fusion sur le nom (§12.2) :
196 + l'égalité stricte plateforme+handle identifie le compte lui-même.
197 + """
187 198 for acc in cr.platforms:
188 if (acc.confidence or 0) < STRONG_THRESHOLD:
189 continue
190 199 row = con.execute(
191 "SELECT creator_id FROM accounts WHERE platform=? AND handle=? "
192 "AND confidence>=?", (acc.platform, acc.handle, STRONG_THRESHOLD)
193 ).fetchone()
200 + "SELECT creator_id FROM accounts WHERE platform=? AND handle=?",
201 + (acc.platform, acc.handle)).fetchone()
194 202 if row:
195 203 return row["creator_id"]
196 204 return None
@@ -226,10 +234,12 @@ def sync_source(con: sqlite3.Connection, source_id: str,
226 234 current = creator_from_row(row)
227 235 if current.status == "opted_out":
228 236 continue # masqué : on ne ré-agrège pas
229 if should_merge(current, cr):
230 merged = merge_creators(current, cr)
231 _write_creator(con, existing_id, merged, row["first_seen"], ts)
232 updated += 1
237 + # _find_existing garantit un compte IDENTIQUE (plateforme+handle)
238 + # partagé : c'est le même compte, la fusion est toujours sûre —
239 + # pas une fusion sur le nom (§12.2)
240 + merged = merge_creators(current, cr)
241 + _write_creator(con, existing_id, merged, row["first_seen"], ts)
242 + updated += 1
233 243 continue
234 244 cid = _unique_id(con, slugify(cr.display_name))
235 245 _write_creator(con, cid, cr, ts, ts)
added tests/test_perimetre_qc.py +98 −0
@@ -0,0 +1,98 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: tests/test_perimetre_qc.py
4 +# Desc: Périmètre québécois (§1-2) — garde QC de la boule de neige (les
5 +# profils reliés IG ramènent des vedettes mondiales sans filtre) et
6 +# rapprochement par compte identique dans sync_source (anti-doublons)
7 +# ==============================================================================
8 +"""Régressions du 2026-08-20 : vedettes américaines + fiches en double.
9 +
10 +1. snowball-ig émettait TOUS les profils reliés Instagram (cristiano,
11 + selenagomez, ferrari, nasa… — 3 767 fiches hors-périmètre en un passage).
12 +2. `_find_existing` exigeait une confiance forte des DEUX côtés : une fiche à
13 + signal `mention` (0.60) n'était jamais retrouvée → sync_source créait une
14 + seconde fiche et l'upsert des comptes lui réassignait le compte, laissant
15 + l'originale orpheline (691 doublons).
16 +"""
17 +from creaka import db
18 +from creaka.connectors.snowball import SnowballConnector
19 +from creaka.identity import account
20 +from creaka.schema import Creator
21 +
22 +
23 +def _creator(name, accounts, **kw):
24 + return Creator(source="test", external_id="", display_name=name,
25 + platforms=accounts, **kw).finalize()
26 +
27 +
28 +def _source_avec_relies(related):
29 + acc = account("instagram", "createur.qc", "profil_source").finalize()
30 + acc.metrics["related_profiles"] = related
31 + return _creator("Créateur QC", [acc])
32 +
33 +
34 +def test_snowball_ecarte_les_vedettes_mondiales():
35 + src = _source_avec_relies([
36 + {"username": "cristiano", "full_name": "Cristiano Ronaldo"},
37 + {"username": "selenagomez", "full_name": "Selena Gomez"},
38 + {"username": "ferrari", "full_name": "Ferrari"},
39 + {"username": "nasa", "full_name": "NASA"},
40 + ])
41 + assert SnowballConnector().enrich([src]) == []
42 +
43 +
44 +def test_marqueur_queb_exige_fin_de_mot():
45 + # faux positifs constatés le 2026-08-20 : « queb » nu matchait dans
46 + # « quebra » (portugais) et « jacquebrazil »
47 + from creaka.connectors.youtube_recherche import is_quebec
48 + assert not is_quebec("tatiquebrabarraco Tati Quebra Barraco")
49 + assert not is_quebec("jacquebrazil")
50 + assert is_quebec("onlyqueb") # queb en fin de mot
51 + assert is_quebec("grainsduquebec") # quebec collé dans un handle
52 + assert is_quebec("Créatrice québécoise à Montréal")
53 +
54 +
55 +def test_snowball_ecarte_les_organismes_qc():
56 + # revue du 2026-08-20 : villes, tourisme, ministères, partis, festivals…
57 + # portent des marqueurs QC mais ne sont pas des créateurs
58 + src = _source_avec_relies([
59 + {"username": "villequebec", "full_name": "Ville de Québec"},
60 + {"username": "tourismecharlevoix", "full_name": "Tourisme Charlevoix"},
61 + {"username": "transports_quebec", "full_name": "Transports Québec"},
62 + {"username": "justepourrirequebec", "full_name": "Juste pour rire"},
63 + {"username": "humoriste.mtl", "full_name": "Humoriste MTL"},
64 + ])
65 + handles = {c.platforms[0].handle
66 + for c in SnowballConnector().enrich([src])}
67 + assert handles == {"humoriste.mtl"}
68 +
69 +
70 +def test_snowball_garde_les_profils_marques_qc():
71 + src = _source_avec_relies([
72 + {"username": "humoriste.mtl", "full_name": "Humoriste MTL"},
73 + {"username": "chroniques.quebec", "full_name": "Chroniques"},
74 + {"username": "katyperry", "full_name": "Katy Perry"},
75 + ])
76 + handles = {c.platforms[0].handle
77 + for c in SnowballConnector().enrich([src])}
78 + assert handles == {"humoriste.mtl", "chroniques.quebec"}
79 +
80 +
81 +def test_compte_identique_fusionne_jamais_de_doublon(tmp_path):
82 + con = db.connect(tmp_path / "t.db")
83 + faible = _creator("misslavoie",
84 + [account("instagram", "misslavoie", "mention")])
85 + s1 = db.sync_source(con, "snowball-ig", [faible])
86 + assert s1["added"] == 1
87 + # ré-observation du MÊME compte avec signal fort (ex. enrichissement) :
88 + # la fiche existante est réutilisée, jamais de seconde fiche
89 + fort = _creator("Jade Lavoie",
90 + [account("instagram", "misslavoie", "profil_source")])
91 + s2 = db.sync_source(con, "autre-source", [fort])
92 + assert s2["added"] == 0 and s2["updated"] == 1
93 + assert con.execute("SELECT count(*) FROM creators").fetchone()[0] == 1
94 + # aucun orphelin : chaque fiche possède encore son compte
95 + orphans = con.execute(
96 + "SELECT count(*) FROM creators WHERE id NOT IN "
97 + "(SELECT creator_id FROM accounts)").fetchone()[0]
98 + assert orphans == 0
99