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%
24.0 KB · 446 lines python
Raw Blame History
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File:   creaka/connectors/youtube_recherche.py4# Desc:   Connecteur DÉCOUVERTE — chaînes YouTube québécoises via la recherche5#         publique (filtre « chaînes »), requêtes thématiques × marqueurs QC.6#         Mode d'accès : requêtes directes (HTML public, ytInitialData).7#         Palier 3 (§9). Filtre conservateur : marqueur québécois OBLIGATOIRE8#         dans le titre ou la description de la chaîne.9# ==============================================================================10"""Découverte massive de chaînes YouTube québécoises.1112La page de résultats (filtre chaînes, sp=EgIQAg==) embarque un JSON13ytInitialData avec, pour chaque chaîne : handle, nom, nb d'abonnés et extrait14de description. On ne crée une fiche QUE si un marqueur québécois figure dans15le titre/la description (jamais d'inférence, §7/§15). Le compte YouTube est le16compte SOURCE de la fiche → signal profil_source (0.98) ; l'enrichissement17(liens de bio, link-in-bio) rattache ensuite ses autres plateformes.18"""19from __future__ import annotations2021import json22import re2324from ..identity import account25from ..normalize import parse_count, slugify, strip_accents26from ..schema import Creator27from .base import BaseConnector2829SEARCH_URL = "https://www.youtube.com/results"30INNERTUBE_URL = "https://www.youtube.com/youtubei/v1/search"31CHANNEL_FILTER = "EgIQAg%3D%3D"          # sp= filtre « chaînes »32_YTID_RE = re.compile(r"var ytInitialData = (\{.*?\});</script>", re.S)33_KEY_RE = re.compile(r'"INNERTUBE_API_KEY":"([^"]+)"')34_VER_RE = re.compile(r'"INNERTUBE_CONTEXT_CLIENT_VERSION":"([^"]+)"')3536# marqueurs québécois (comparés SANS accents, en minuscules)37# ⚠ frontières de mot sur les toponymes courts/piégeux : « levis » matchait38# dans « teLEVISion », « estrie » dans « tapESTRIEs » (corrigé vague 3) ;39# « queb » nu matchait « QUEBra » (portugais, tatiQUEBrabarraco) et40# « jacQUEBrazil » (corrigé vague 4) — « quebec » complet n'importe où, ou41# « queb » en fin de mot (onlyqueb, grainsduquebec restent couverts)42_QC_MARKERS = re.compile(43    r"quebec|queb(?![a-z])|montreal|\bmtl\b|saguenay|gaspesie|mauricie|"44    r"\bestrie\b|outaouais|"45    r"abitibi|\blevis\b|sherbrooke|trois-rivieres|gatineau|\blaval\b|longueuil|"46    r"rimouski|chicoutimi|drummondville|\bgranby\b|\bbeauce\b|charlevoix|"47    r"lanaudiere|laurentides|monteregie|temiscamingue|\bjoliette\b|"48    r"victoriaville|\bfleuve\b|rive-sud|rive-nord|ville de quebec")4950# requêtes thématiques → niche par défaut de la fiche51QUERIES: list[tuple[str, str]] = [52    ("vlog québécois", "lifestyle"), ("youtubeur québécois", "lifestyle"),53    ("humour québécois", "humour"), ("humoriste québécois", "humour"),54    ("cuisine québécoise", "cuisine"), ("recettes québec", "cuisine"),55    ("gaming québécois", "gaming"), ("gamer québec", "gaming"),56    ("chasse québec", "plein-air"), ("pêche québec", "plein-air"),57    ("plein air québec", "plein-air"), ("camping québec", "plein-air"),58    ("musique québécoise", "musique"), ("rap québécois", "musique"),59    ("maquillage québec", "beaute"), ("beauté québécoise", "beaute"),60    ("mode québécoise", "mode"), ("fitness québec", "sport-fitness"),61    ("entraînement québécois", "sport-fitness"), ("hockey québec", "sport-fitness"),62    ("famille québécoise vlog", "famille-parentalite"),63    ("maman québécoise", "famille-parentalite"),64    ("voyage québécois", "voyage"), ("finance québec", "finance-affaires"),65    ("immobilier québec", "finance-affaires"), ("tech québec", "tech"),66    ("balado québécois", "actualite-opinion"), ("podcast québécois", "actualite-opinion"),67    ("éducation québec", "education"), ("science québec", "education"),68    ("art québécois", "arts"), ("danse québec", "danse"),69    ("resto montréal", "bouffe-resto"), ("bouffe québec", "bouffe-resto"),70    ("vlog montréal", "lifestyle"), ("vlog ville de québec", "lifestyle"),71    ("agriculture québec", "autre"), ("auto québec", "autre"),72    ("santé bien-être québec", "sante-mieux-etre"),73    ("actualité québécoise", "actualite-opinion"),74    # villes × vlog/scène locale75    ("vlog sherbrooke", "lifestyle"), ("vlog gatineau", "lifestyle"),76    ("vlog saguenay", "lifestyle"), ("vlog trois-rivières", "lifestyle"),77    ("vlog gaspésie", "voyage"), ("vlog rimouski", "lifestyle"),78    ("vlog abitibi", "lifestyle"), ("vlog lévis", "lifestyle"),79    ("vlog laval", "lifestyle"), ("vlog longueuil", "lifestyle"),80    ("vlog drummondville", "lifestyle"), ("vlog granby", "lifestyle"),81    # gaming & streaming82    ("minecraft québécois", "gaming"), ("fortnite québécois", "gaming"),83    ("streamer québécois", "gaming"), ("twitch québécois", "gaming"),84    ("jeux vidéo québec", "gaming"), ("rétro gaming québec", "gaming"),85    # niches ciblées86    ("asmr québécois", "lifestyle"), ("vanlife québec", "voyage"),87    ("ski québec", "plein-air"), ("vélo québec", "sport-fitness"),88    ("course à pied québec", "sport-fitness"), ("musculation québec", "sport-fitness"),89    ("nutrition québec", "sante-mieux-etre"), ("yoga québec", "sante-mieux-etre"),90    ("rénovation québec", "autre"), ("menuiserie québec", "autre"),91    ("jardinage québec", "plein-air"), ("moto québec", "autre"),92    ("auto détail québec", "autre"), ("mécanique québec", "autre"),93    ("crypto québec", "finance-affaires"), ("bourse québec", "finance-affaires"),94    ("entrepreneur québécois", "finance-affaires"),95    ("développeur québécois", "tech"), ("programmation québec", "tech"),96    ("photographie québec", "arts"), ("dessin québécois", "arts"),97    ("couture québec", "arts"), ("tricot québec", "arts"),98    ("chasse orignal québec", "plein-air"), ("motoneige québec", "plein-air"),99    ("quatre-roues québec", "plein-air"), ("survie en forêt québec", "plein-air"),100    ("astronomie québec", "education"), ("histoire du québec", "education"),101    ("hockey canadiens de montréal", "sport-fitness"),102    ("pêche mouche québec", "plein-air"), ("kayak québec", "plein-air"),103    ("randonnée québec", "plein-air"), ("microbrasserie québec", "bouffe-resto"),104    ("bbq québec", "cuisine"), ("pâtisserie québec", "cuisine"),105    ("végane québec", "cuisine"), ("chef québécois", "cuisine"),106    ("critique resto montréal", "bouffe-resto"),107    ("maquilleuse québécoise", "beaute"), ("coiffure québec", "beaute"),108    ("soins de peau québec", "beaute"), ("friperie montréal", "mode"),109    ("streetwear montréal", "mode"), ("chanteur québécois", "musique"),110    ("chanteuse québécoise", "musique"), ("guitare québec", "musique"),111    ("dj québec", "musique"), ("country québécois", "musique"),112    ("télé-réalité québec", "lifestyle"), ("occupation double", "lifestyle"),113    ("humoriste relève québec", "humour"), ("stand up québec", "humour"),114    ("améliorer son français québec", "education"),115    ("prof québécois", "education"), ("garderie maman québec", "famille-parentalite"),116    ("grossesse québec", "famille-parentalite"),117    ("astuces parents québec", "famille-parentalite"),118    ("spiritualité québec", "sante-mieux-etre"),119    ("astrologie québec", "autre"), ("paranormal québec", "autre"),120    ("urbex québec", "autre"), ("faits divers québec", "actualite-opinion"),121    ("politique québécoise", "actualite-opinion"),122    ("nouvelles québec", "actualite-opinion"),123    # créateurs anglophones/bilingues (les marqueurs montreal/mtl/quebec124    # figurent dans _QC_MARKERS — même filtre conservateur)125    ("montreal vlog", "lifestyle"), ("montreal vlogger", "lifestyle"),126    ("montreal youtuber", "lifestyle"), ("living in montreal", "lifestyle"),127    ("montreal food", "bouffe-resto"), ("montreal foodie", "bouffe-resto"),128    ("montreal restaurants", "bouffe-resto"), ("montreal cooking", "cuisine"),129    ("montreal gaming", "gaming"), ("montreal streamer", "gaming"),130    ("montreal comedy", "humour"), ("montreal standup", "humour"),131    ("montreal music", "musique"), ("montreal rap", "musique"),132    ("mtl rap", "musique"), ("montreal producer", "musique"),133    ("montreal fitness", "sport-fitness"), ("montreal gym", "sport-fitness"),134    ("montreal makeup", "beaute"), ("montreal beauty", "beaute"),135    ("montreal fashion", "mode"), ("montreal streetwear", "mode"),136    ("montreal thrift", "mode"), ("montreal barber", "beaute"),137    ("montreal tech", "tech"), ("montreal developer", "tech"),138    ("montreal photography", "arts"), ("montreal artist", "arts"),139    ("montreal dance", "danse"), ("montreal travel", "voyage"),140    ("montreal real estate", "finance-affaires"),141    ("montreal finance", "finance-affaires"),142    ("montreal podcast", "actualite-opinion"),143    ("montreal family vlog", "famille-parentalite"),144    ("quebec city vlog", "lifestyle"), ("quebec city food", "bouffe-resto"),145    ("quebec gaming", "gaming"), ("quebec outdoors", "plein-air"),146    ("quebec fishing", "plein-air"), ("quebec hunting", "plein-air"),147    ("quebec snowboard", "plein-air"), ("quebec skiing", "plein-air"),148    ("quebec history", "education"), ("learn french quebec", "education"),149    ("quebec french lessons", "education"), ("anglo montreal", "lifestyle"),150    ("laval vlogger", "lifestyle"), ("gatineau ottawa vlog", "lifestyle"),151]152153# --- longue traîne : villes/MRC × créneaux (nano/micro créateurs locaux) --------154# On ne vise PAS que les mégastars : chaque ville a ses vloggeurs, gamers,155# chasseurs, cuisiniers… Le filtre _QC_MARKERS garde la prudence d'attribution.156_TOWNS = [157    "montréal", "ville de québec", "laval", "gatineau", "longueuil",158    "sherbrooke", "lévis", "saguenay", "trois-rivières", "terrebonne",159    "brossard", "repentigny", "drummondville", "saint-jérôme", "granby",160    "blainville", "saint-hyacinthe", "shawinigan", "dollard-des-ormeaux",161    "rimouski", "châteauguay", "saint-jean-sur-richelieu", "mascouche",162    "joliette", "victoriaville", "salaberry-de-valleyfield", "rouyn-noranda",163    "chicoutimi", "alma", "sept-îles", "baie-comeau", "val-d'or", "matane",164    "rivière-du-loup", "sorel-tracy", "thetford mines", "magog", "cowansville",165    "la tuque", "mont-laurier", "mont-tremblant", "sainte-agathe",166    "saint-georges beauce", "montmagny", "la malbaie", "baie-saint-paul",167    "gaspé", "chandler", "amqui", "dolbeau-mistassini", "roberval",168    "chibougamau", "lachute", "varennes", "chambly", "boucherville",169]170_TOWN_THEMES = [("vlog", "lifestyle"), ("youtubeur", "lifestyle"),171                ("gaming", "gaming"), ("chasse pêche", "plein-air"),172                ("humour", "humour"),173                # vague 4 : la longue traîne locale couvre aussi la bouffe, la174                # forme, la beauté, la musique, la famille et les balados175                ("cuisine", "cuisine"), ("resto", "bouffe-resto"),176                ("fitness", "sport-fitness"), ("beauté", "beaute"),177                ("musique", "musique"), ("famille", "famille-parentalite"),178                ("balado", "actualite-opinion"), ("plein air", "plein-air")]179for _t in _TOWNS:180    for _th, _n in _TOWN_THEMES:181        QUERIES.append((f"{_th} {_t}", _n))182183# --- longue traîne : passe-temps & métiers × marqueur QC ------------------------184_HOBBIES = [185    ("skateboard", "sport-fitness"), ("snowboard", "plein-air"),186    ("pickleball", "sport-fitness"), ("golf", "sport-fitness"),187    ("baseball", "sport-fitness"), ("soccer", "sport-fitness"),188    ("basketball", "sport-fitness"), ("boxe", "sport-fitness"),189    ("mma", "sport-fitness"), ("crossfit", "sport-fitness"),190    ("échecs", "gaming"), ("donjons et dragons", "gaming"),191    ("cartes pokémon", "gaming"), ("warhammer", "gaming"),192    ("speedrun", "gaming"), ("simracing", "gaming"),193    ("lego", "autre"), ("maquette", "arts"), ("bois", "arts"),194    ("forge", "arts"), ("céramique", "arts"), ("peinture", "arts"),195    ("aquarelle", "arts"), ("calligraphie", "arts"),196    ("astrophotographie", "education"), ("ornithologie", "plein-air"),197    ("apiculture", "plein-air"), ("permaculture", "plein-air"),198    ("homesteading", "plein-air"), ("érablière", "plein-air"),199    ("acériculture", "plein-air"), ("motocross", "autre"),200    ("vtt", "plein-air"), ("side-by-side", "plein-air"),201    ("camion", "autre"), ("remorquage", "autre"), ("soudure", "autre"),202    ("électricien", "autre"), ("plombier", "autre"), ("excavation", "autre"),203    ("déneigement", "autre"), ("paysagement", "autre"),204    ("courtier immobilier", "finance-affaires"), ("comptable", "finance-affaires"),205    ("avocat", "education"), ("infirmière", "sante-mieux-etre"),206    ("massothérapie", "sante-mieux-etre"), ("ostéopathie", "sante-mieux-etre"),207    ("naturopathie", "sante-mieux-etre"), ("psychologie", "sante-mieux-etre"),208    ("méditation", "sante-mieux-etre"), ("tarot", "autre"),209    ("généalogie", "education"), ("philosophie", "education"),210    ("booktube", "education"), ("lecture", "education"),211    ("cinéma", "actualite-opinion"), ("séries télé", "actualite-opinion"),212    ("anime", "gaming"), ("manga", "gaming"), ("cosplay", "arts"),213    ("drag", "arts"), ("magie", "arts"), ("cirque", "arts"),214    ("violon", "musique"), ("piano", "musique"), ("batterie", "musique"),215    ("chorale", "musique"), ("trad", "musique"), ("métal", "musique"),216    ("punk", "musique"), ("folk", "musique"),217    ("fromage", "bouffe-resto"), ("vin", "bouffe-resto"),218    ("café", "bouffe-resto"), ("boulangerie", "cuisine"),219    ("cabane à sucre", "bouffe-resto"), ("poutine", "bouffe-resto"),220    ("food truck", "bouffe-resto"), ("mixologie", "bouffe-resto"),221    ("couture", "arts"), ("crochet", "arts"), ("broderie", "arts"),222    ("thrift", "mode"), ("sneakers", "mode"), ("barbier", "beaute"),223    ("ongles", "beaute"), ("tatouage", "arts"), ("esthétique", "beaute"),224    ("organisation maison", "lifestyle"), ("minimalisme", "lifestyle"),225    ("van aménagé", "voyage"), ("chalet", "lifestyle"),226    ("astuces budget", "finance-affaires"), ("coupons", "finance-affaires"),227    ("frugal", "finance-affaires"), ("bourse dividendes", "finance-affaires"),228    ("intelligence artificielle", "tech"), ("cybersécurité", "tech"),229    ("impression 3d", "tech"), ("domotique", "tech"), ("drones", "tech"),230    # vague 4 : encore plus de passe-temps, sports, métiers et scènes231    ("escalade", "sport-fitness"), ("trail", "sport-fitness"),232    ("triathlon", "sport-fitness"), ("powerlifting", "sport-fitness"),233    ("arts martiaux", "sport-fitness"), ("jiu-jitsu", "sport-fitness"),234    ("karaté", "sport-fitness"), ("boxe thaï", "sport-fitness"),235    ("patinage", "sport-fitness"), ("ringuette", "sport-fitness"),236    ("curling", "sport-fitness"), ("cheerleading", "sport-fitness"),237    ("gymnastique", "sport-fitness"), ("natation", "sport-fitness"),238    ("plongée", "plein-air"), ("voile", "plein-air"),239    ("kayak de mer", "plein-air"), ("canot", "plein-air"),240    ("raquette", "plein-air"), ("ski de fond", "plein-air"),241    ("ski hors-piste", "plein-air"), ("escapade", "voyage"),242    ("roadtrip", "voyage"), ("croisière", "voyage"),243    ("aquariophilie", "autre"), ("reptiles", "autre"),244    ("chiens", "autre"), ("chats", "autre"), ("chevaux", "plein-air"),245    ("équitation", "plein-air"), ("fermette", "plein-air"),246    ("poules", "plein-air"), ("potager", "plein-air"),247    ("champignons", "plein-air"), ("mycologie", "plein-air"),248    ("herboristerie", "sante-mieux-etre"), ("zéro déchet", "lifestyle"),249    ("autosuffisance", "plein-air"), ("survivalisme", "plein-air"),250    ("vinyle", "musique"), ("audiophile", "tech"),251    ("home studio", "musique"), ("beatmaking", "musique"),252    ("production musicale", "musique"), ("ukulélé", "musique"),253    ("saxophone", "musique"), ("harmonica", "musique"),254    ("accordéon", "musique"), ("chant", "musique"),255    ("billard", "autre"), ("poker", "autre"), ("fléchettes", "autre"),256    ("airsoft", "autre"), ("paintball", "autre"),257    ("modélisme", "arts"), ("figurines", "arts"), ("bijoux", "arts"),258    ("savonnerie", "arts"), ("chandelles", "arts"),259    ("macramé", "arts"), ("scrapbooking", "arts"),260    ("philatélie", "education"), ("numismatique", "education"),261    ("antiquités", "autre"), ("brocante", "autre"),262    ("horlogerie", "arts"), ("restauration meubles", "arts"),263    ("ébénisterie", "arts"), ("charpenterie", "autre"),264    ("maçonnerie", "autre"), ("toiture", "autre"),265    ("mécanique diesel", "autre"), ("carrosserie", "autre"),266    ("détailing", "autre"), ("camion lourd", "autre"),267    ("agriculture laitière", "plein-air"), ("grandes cultures", "plein-air"),268    ("boucherie", "cuisine"), ("charcuterie", "cuisine"),269    ("fermentation", "cuisine"), ("kombucha", "cuisine"),270    ("cidre", "bouffe-resto"), ("hydromel", "bouffe-resto"),271    ("distillerie", "bouffe-resto"), ("torréfaction", "bouffe-resto"),272    ("sommelier", "bouffe-resto"), ("cocktails", "bouffe-resto"),273    ("orthopédagogie", "education"), ("orthophonie", "education"),274    ("tdah", "sante-mieux-etre"), ("autisme", "sante-mieux-etre"),275    ("kinésiologie", "sante-mieux-etre"), ("chiropratique", "sante-mieux-etre"),276    ("dermatologie", "beaute"), ("nutritionniste", "sante-mieux-etre"),277    ("sage-femme", "famille-parentalite"), ("doula", "famille-parentalite"),278    ("homeschooling", "famille-parentalite"), ("école à la maison", "famille-parentalite"),279    ("astuces ménage", "lifestyle"), ("décoration", "lifestyle"),280    ("design intérieur", "lifestyle"), ("aménagement paysager", "autre"),281    ("piscine", "autre"), ("spa", "autre"),282    ("courtier hypothécaire", "finance-affaires"),283    ("planificateur financier", "finance-affaires"),284    ("assurance", "finance-affaires"), ("fiscalité", "finance-affaires"),285    ("dropshipping", "finance-affaires"), ("commerce en ligne", "finance-affaires"),286    ("marketing numérique", "finance-affaires"), ("freelance", "finance-affaires"),287    ("linguistique", "education"), ("vulgarisation", "education"),288    ("archéologie", "education"), ("géographie", "education"),289    ("météo", "education"), ("aviation", "tech"),290    ("simulateur de vol", "gaming"), ("trains", "autre"),291    ("camping-car", "voyage"), ("vr roulotte", "voyage"),292    ("pêche blanche", "plein-air"), ("trappage", "plein-air"),293    ("taxidermie", "autre"), ("coutellerie", "arts"),294    ("armes à feu", "plein-air"), ("tir à l'arc", "plein-air"),295]296for _h, _n in _HOBBIES:297    QUERIES.append((f"{_h} québec", _n))298    QUERIES.append((f"{_h} québécois", _n))299300301def _walk_channels(node, out: list) -> None:302    if isinstance(node, dict):303        if "channelRenderer" in node:304            out.append(node["channelRenderer"])305        for v in node.values():306            _walk_channels(v, out)307    elif isinstance(node, list):308        for item in node:309            _walk_channels(item, out)310311312def _walk_token(node) -> str | None:313    """Jeton de continuation (page suivante) n'importe où dans l'arbre JSON."""314    if isinstance(node, dict):315        cmd = node.get("continuationCommand")316        if isinstance(cmd, dict) and cmd.get("token"):317            return cmd["token"]318        for v in node.values():319            tok = _walk_token(v)320            if tok:321                return tok322    elif isinstance(node, list):323        for item in node:324            tok = _walk_token(item)325            if tok:326                return tok327    return None328329330def parse_channels(html: str) -> list[dict]:331    """HTML de résultats → [{handle, channel_id, name, subs, description}]."""332    m = _YTID_RE.search(html)333    if not m:334        return []335    try:336        data = json.loads(m.group(1))337    except Exception:338        return []339    return channels_from_data(data)340341342def channels_from_data(data) -> list[dict]:343    """Arbre JSON (page 1 ou réponse de continuation) → liste de chaînes."""344    renderers: list[dict] = []345    _walk_channels(data, renderers)346    channels = []347    for r in renderers:348        try:349            handle = ((r.get("navigationEndpoint") or {}).get("browseEndpoint")350                      or {}).get("canonicalBaseUrl", "").lstrip("/")351            name = ((r.get("title") or {}).get("simpleText")) or ""352            desc = "".join(run.get("text", "") for run in353                           ((r.get("descriptionSnippet") or {}).get("runs") or []))354            subs_txt = ((r.get("videoCountText") or {}).get("simpleText")) or \355                       ((r.get("subscriberCountText") or {}).get("simpleText")) or ""356            # avatar « gratuit » : le renderer embarque les miniatures du profil357            # → meilleure résolution (URLs souvent protocol-relative « //yt3… »)358            thumbs = (r.get("thumbnail") or {}).get("thumbnails") or []359            avatar = None360            if thumbs:361                avatar = max(thumbs,362                             key=lambda t: t.get("width") or 0).get("url")363                if avatar and avatar.startswith("//"):364                    avatar = "https:" + avatar365            channels.append({"handle": handle, "channel_id": r.get("channelId", ""),366                             "name": name, "subs": parse_count(subs_txt),367                             "description": desc, "avatar": avatar})368        except Exception:369            continue370    return channels371372373def is_quebec(text: str) -> bool:374    return bool(_QC_MARKERS.search(strip_accents(text or "").lower()))375376377class YouTubeRechercheConnector(BaseConnector):378    source_id = "youtube-recherche"379    kind = "discovery"380    request_delay = 1.0381    max_queries = 2500382    max_pages_per_query = 4   # PROFONDEUR : pages de continuation (~20 chaînes/page)383384    def _collect(self, channels: list[dict], niche: str, seen: set[str],385                 creators: list[Creator]) -> None:386        for ch in channels:387            handle = ch["handle"].lstrip("@")388            key = handle or ch["channel_id"]389            if not key or key in seen:390                continue391            # filtre conservateur : marqueur QC requis dans titre+description392            if not is_quebec(f"{ch['name']} {ch['description']}"):393                continue394            seen.add(key)395            acc = account("youtube", handle or ch["channel_id"], "profil_source",396                          followers=ch["subs"])397            creators.append(Creator(398                source=self.source_id,399                external_id=slugify(ch["name"]),400                display_name=ch["name"],401                bio=ch["description"][:1200],402                niches=[niche],403                creator_type="youtubeur",404                avatar_url=ch.get("avatar"),405                platforms=[acc],406                source_ids=[f"{self.source_id}:{key}"],407            ))408409    def fetch(self) -> list[Creator]:410        creators: list[Creator] = []411        seen: set[str] = set()412        for query, niche in QUERIES[:self.max_queries]:413            try:414                html = self.get(415                    SEARCH_URL,416                    params={"search_query": query, "sp": "EgIQAg=="},417                    headers={"Accept-Language": "fr-CA,fr;q=0.9"}).text418            except Exception:419                continue420            m = _YTID_RE.search(html)421            if not m:422                continue423            try:424                data = json.loads(m.group(1))425            except Exception:426                continue427            key_m, ver_m = _KEY_RE.search(html), _VER_RE.search(html)428            for page in range(self.max_pages_per_query):429                self._collect(channels_from_data(data), niche, seen, creators)430                token = _walk_token(data)431                if not token or not key_m or not ver_m \432                        or page == self.max_pages_per_query - 1:433                    break434                try:  # pages suivantes via l'API interne de la page publique435                    data = self.post(436                        INNERTUBE_URL, params={"key": key_m.group(1)},437                        json={"context": {"client": {438                                  "clientName": "WEB",439                                  "clientVersion": ver_m.group(1),440                                  "hl": "fr", "gl": "CA"}},441                              "continuation": token},442                        headers={"Accept-Language": "fr-CA,fr;q=0.9"}).json()443                except Exception:444                    break445        return creators446