# ============================================================================== # Author: Simon-Pierre Boucher # File: creaka/connectors/youtube_recherche.py # Desc: Connecteur DÉCOUVERTE — chaînes YouTube québécoises via la recherche # publique (filtre « chaînes »), requêtes thématiques × marqueurs QC. # Mode d'accès : requêtes directes (HTML public, ytInitialData). # Palier 3 (§9). Filtre conservateur : marqueur québécois OBLIGATOIRE # dans le titre ou la description de la chaîne. # ============================================================================== """Découverte massive de chaînes YouTube québécoises. La page de résultats (filtre chaînes, sp=EgIQAg==) embarque un JSON ytInitialData avec, pour chaque chaîne : handle, nom, nb d'abonnés et extrait de description. On ne crée une fiche QUE si un marqueur québécois figure dans le titre/la description (jamais d'inférence, §7/§15). Le compte YouTube est le compte SOURCE de la fiche → signal profil_source (0.98) ; l'enrichissement (liens de bio, link-in-bio) rattache ensuite ses autres plateformes. """ from __future__ import annotations import json import re from ..identity import account from ..normalize import parse_count, slugify, strip_accents from ..schema import Creator from .base import BaseConnector SEARCH_URL = "https://www.youtube.com/results" INNERTUBE_URL = "https://www.youtube.com/youtubei/v1/search" CHANNEL_FILTER = "EgIQAg%3D%3D" # sp= filtre « chaînes » _YTID_RE = re.compile(r"var ytInitialData = (\{.*?\});", re.S) _KEY_RE = re.compile(r'"INNERTUBE_API_KEY":"([^"]+)"') _VER_RE = re.compile(r'"INNERTUBE_CONTEXT_CLIENT_VERSION":"([^"]+)"') # marqueurs québécois (comparés SANS accents, en minuscules) # ⚠ frontières de mot sur les toponymes courts/piégeux : « levis » matchait # dans « teLEVISion », « estrie » dans « tapESTRIEs » (corrigé vague 3) ; # « queb » nu matchait « QUEBra » (portugais, tatiQUEBrabarraco) et # « jacQUEBrazil » (corrigé vague 4) — « quebec » complet n'importe où, ou # « queb » en fin de mot (onlyqueb, grainsduquebec restent couverts) _QC_MARKERS = re.compile( r"quebec|queb(?![a-z])|montreal|\bmtl\b|saguenay|gaspesie|mauricie|" r"\bestrie\b|outaouais|" r"abitibi|\blevis\b|sherbrooke|trois-rivieres|gatineau|\blaval\b|longueuil|" r"rimouski|chicoutimi|drummondville|\bgranby\b|\bbeauce\b|charlevoix|" r"lanaudiere|laurentides|monteregie|temiscamingue|\bjoliette\b|" r"victoriaville|\bfleuve\b|rive-sud|rive-nord|ville de quebec") # requêtes thématiques → niche par défaut de la fiche QUERIES: list[tuple[str, str]] = [ ("vlog québécois", "lifestyle"), ("youtubeur québécois", "lifestyle"), ("humour québécois", "humour"), ("humoriste québécois", "humour"), ("cuisine québécoise", "cuisine"), ("recettes québec", "cuisine"), ("gaming québécois", "gaming"), ("gamer québec", "gaming"), ("chasse québec", "plein-air"), ("pêche québec", "plein-air"), ("plein air québec", "plein-air"), ("camping québec", "plein-air"), ("musique québécoise", "musique"), ("rap québécois", "musique"), ("maquillage québec", "beaute"), ("beauté québécoise", "beaute"), ("mode québécoise", "mode"), ("fitness québec", "sport-fitness"), ("entraînement québécois", "sport-fitness"), ("hockey québec", "sport-fitness"), ("famille québécoise vlog", "famille-parentalite"), ("maman québécoise", "famille-parentalite"), ("voyage québécois", "voyage"), ("finance québec", "finance-affaires"), ("immobilier québec", "finance-affaires"), ("tech québec", "tech"), ("balado québécois", "actualite-opinion"), ("podcast québécois", "actualite-opinion"), ("éducation québec", "education"), ("science québec", "education"), ("art québécois", "arts"), ("danse québec", "danse"), ("resto montréal", "bouffe-resto"), ("bouffe québec", "bouffe-resto"), ("vlog montréal", "lifestyle"), ("vlog ville de québec", "lifestyle"), ("agriculture québec", "autre"), ("auto québec", "autre"), ("santé bien-être québec", "sante-mieux-etre"), ("actualité québécoise", "actualite-opinion"), # villes × vlog/scène locale ("vlog sherbrooke", "lifestyle"), ("vlog gatineau", "lifestyle"), ("vlog saguenay", "lifestyle"), ("vlog trois-rivières", "lifestyle"), ("vlog gaspésie", "voyage"), ("vlog rimouski", "lifestyle"), ("vlog abitibi", "lifestyle"), ("vlog lévis", "lifestyle"), ("vlog laval", "lifestyle"), ("vlog longueuil", "lifestyle"), ("vlog drummondville", "lifestyle"), ("vlog granby", "lifestyle"), # gaming & streaming ("minecraft québécois", "gaming"), ("fortnite québécois", "gaming"), ("streamer québécois", "gaming"), ("twitch québécois", "gaming"), ("jeux vidéo québec", "gaming"), ("rétro gaming québec", "gaming"), # niches ciblées ("asmr québécois", "lifestyle"), ("vanlife québec", "voyage"), ("ski québec", "plein-air"), ("vélo québec", "sport-fitness"), ("course à pied québec", "sport-fitness"), ("musculation québec", "sport-fitness"), ("nutrition québec", "sante-mieux-etre"), ("yoga québec", "sante-mieux-etre"), ("rénovation québec", "autre"), ("menuiserie québec", "autre"), ("jardinage québec", "plein-air"), ("moto québec", "autre"), ("auto détail québec", "autre"), ("mécanique québec", "autre"), ("crypto québec", "finance-affaires"), ("bourse québec", "finance-affaires"), ("entrepreneur québécois", "finance-affaires"), ("développeur québécois", "tech"), ("programmation québec", "tech"), ("photographie québec", "arts"), ("dessin québécois", "arts"), ("couture québec", "arts"), ("tricot québec", "arts"), ("chasse orignal québec", "plein-air"), ("motoneige québec", "plein-air"), ("quatre-roues québec", "plein-air"), ("survie en forêt québec", "plein-air"), ("astronomie québec", "education"), ("histoire du québec", "education"), ("hockey canadiens de montréal", "sport-fitness"), ("pêche mouche québec", "plein-air"), ("kayak québec", "plein-air"), ("randonnée québec", "plein-air"), ("microbrasserie québec", "bouffe-resto"), ("bbq québec", "cuisine"), ("pâtisserie québec", "cuisine"), ("végane québec", "cuisine"), ("chef québécois", "cuisine"), ("critique resto montréal", "bouffe-resto"), ("maquilleuse québécoise", "beaute"), ("coiffure québec", "beaute"), ("soins de peau québec", "beaute"), ("friperie montréal", "mode"), ("streetwear montréal", "mode"), ("chanteur québécois", "musique"), ("chanteuse québécoise", "musique"), ("guitare québec", "musique"), ("dj québec", "musique"), ("country québécois", "musique"), ("télé-réalité québec", "lifestyle"), ("occupation double", "lifestyle"), ("humoriste relève québec", "humour"), ("stand up québec", "humour"), ("améliorer son français québec", "education"), ("prof québécois", "education"), ("garderie maman québec", "famille-parentalite"), ("grossesse québec", "famille-parentalite"), ("astuces parents québec", "famille-parentalite"), ("spiritualité québec", "sante-mieux-etre"), ("astrologie québec", "autre"), ("paranormal québec", "autre"), ("urbex québec", "autre"), ("faits divers québec", "actualite-opinion"), ("politique québécoise", "actualite-opinion"), ("nouvelles québec", "actualite-opinion"), # créateurs anglophones/bilingues (les marqueurs montreal/mtl/quebec # figurent dans _QC_MARKERS — même filtre conservateur) ("montreal vlog", "lifestyle"), ("montreal vlogger", "lifestyle"), ("montreal youtuber", "lifestyle"), ("living in montreal", "lifestyle"), ("montreal food", "bouffe-resto"), ("montreal foodie", "bouffe-resto"), ("montreal restaurants", "bouffe-resto"), ("montreal cooking", "cuisine"), ("montreal gaming", "gaming"), ("montreal streamer", "gaming"), ("montreal comedy", "humour"), ("montreal standup", "humour"), ("montreal music", "musique"), ("montreal rap", "musique"), ("mtl rap", "musique"), ("montreal producer", "musique"), ("montreal fitness", "sport-fitness"), ("montreal gym", "sport-fitness"), ("montreal makeup", "beaute"), ("montreal beauty", "beaute"), ("montreal fashion", "mode"), ("montreal streetwear", "mode"), ("montreal thrift", "mode"), ("montreal barber", "beaute"), ("montreal tech", "tech"), ("montreal developer", "tech"), ("montreal photography", "arts"), ("montreal artist", "arts"), ("montreal dance", "danse"), ("montreal travel", "voyage"), ("montreal real estate", "finance-affaires"), ("montreal finance", "finance-affaires"), ("montreal podcast", "actualite-opinion"), ("montreal family vlog", "famille-parentalite"), ("quebec city vlog", "lifestyle"), ("quebec city food", "bouffe-resto"), ("quebec gaming", "gaming"), ("quebec outdoors", "plein-air"), ("quebec fishing", "plein-air"), ("quebec hunting", "plein-air"), ("quebec snowboard", "plein-air"), ("quebec skiing", "plein-air"), ("quebec history", "education"), ("learn french quebec", "education"), ("quebec french lessons", "education"), ("anglo montreal", "lifestyle"), ("laval vlogger", "lifestyle"), ("gatineau ottawa vlog", "lifestyle"), ] # --- longue traîne : villes/MRC × créneaux (nano/micro créateurs locaux) -------- # On ne vise PAS que les mégastars : chaque ville a ses vloggeurs, gamers, # chasseurs, cuisiniers… Le filtre _QC_MARKERS garde la prudence d'attribution. _TOWNS = [ "montréal", "ville de québec", "laval", "gatineau", "longueuil", "sherbrooke", "lévis", "saguenay", "trois-rivières", "terrebonne", "brossard", "repentigny", "drummondville", "saint-jérôme", "granby", "blainville", "saint-hyacinthe", "shawinigan", "dollard-des-ormeaux", "rimouski", "châteauguay", "saint-jean-sur-richelieu", "mascouche", "joliette", "victoriaville", "salaberry-de-valleyfield", "rouyn-noranda", "chicoutimi", "alma", "sept-îles", "baie-comeau", "val-d'or", "matane", "rivière-du-loup", "sorel-tracy", "thetford mines", "magog", "cowansville", "la tuque", "mont-laurier", "mont-tremblant", "sainte-agathe", "saint-georges beauce", "montmagny", "la malbaie", "baie-saint-paul", "gaspé", "chandler", "amqui", "dolbeau-mistassini", "roberval", "chibougamau", "lachute", "varennes", "chambly", "boucherville", ] _TOWN_THEMES = [("vlog", "lifestyle"), ("youtubeur", "lifestyle"), ("gaming", "gaming"), ("chasse pêche", "plein-air"), ("humour", "humour"), # vague 4 : la longue traîne locale couvre aussi la bouffe, la # forme, la beauté, la musique, la famille et les balados ("cuisine", "cuisine"), ("resto", "bouffe-resto"), ("fitness", "sport-fitness"), ("beauté", "beaute"), ("musique", "musique"), ("famille", "famille-parentalite"), ("balado", "actualite-opinion"), ("plein air", "plein-air")] for _t in _TOWNS: for _th, _n in _TOWN_THEMES: QUERIES.append((f"{_th} {_t}", _n)) # --- longue traîne : passe-temps & métiers × marqueur QC ------------------------ _HOBBIES = [ ("skateboard", "sport-fitness"), ("snowboard", "plein-air"), ("pickleball", "sport-fitness"), ("golf", "sport-fitness"), ("baseball", "sport-fitness"), ("soccer", "sport-fitness"), ("basketball", "sport-fitness"), ("boxe", "sport-fitness"), ("mma", "sport-fitness"), ("crossfit", "sport-fitness"), ("échecs", "gaming"), ("donjons et dragons", "gaming"), ("cartes pokémon", "gaming"), ("warhammer", "gaming"), ("speedrun", "gaming"), ("simracing", "gaming"), ("lego", "autre"), ("maquette", "arts"), ("bois", "arts"), ("forge", "arts"), ("céramique", "arts"), ("peinture", "arts"), ("aquarelle", "arts"), ("calligraphie", "arts"), ("astrophotographie", "education"), ("ornithologie", "plein-air"), ("apiculture", "plein-air"), ("permaculture", "plein-air"), ("homesteading", "plein-air"), ("érablière", "plein-air"), ("acériculture", "plein-air"), ("motocross", "autre"), ("vtt", "plein-air"), ("side-by-side", "plein-air"), ("camion", "autre"), ("remorquage", "autre"), ("soudure", "autre"), ("électricien", "autre"), ("plombier", "autre"), ("excavation", "autre"), ("déneigement", "autre"), ("paysagement", "autre"), ("courtier immobilier", "finance-affaires"), ("comptable", "finance-affaires"), ("avocat", "education"), ("infirmière", "sante-mieux-etre"), ("massothérapie", "sante-mieux-etre"), ("ostéopathie", "sante-mieux-etre"), ("naturopathie", "sante-mieux-etre"), ("psychologie", "sante-mieux-etre"), ("méditation", "sante-mieux-etre"), ("tarot", "autre"), ("généalogie", "education"), ("philosophie", "education"), ("booktube", "education"), ("lecture", "education"), ("cinéma", "actualite-opinion"), ("séries télé", "actualite-opinion"), ("anime", "gaming"), ("manga", "gaming"), ("cosplay", "arts"), ("drag", "arts"), ("magie", "arts"), ("cirque", "arts"), ("violon", "musique"), ("piano", "musique"), ("batterie", "musique"), ("chorale", "musique"), ("trad", "musique"), ("métal", "musique"), ("punk", "musique"), ("folk", "musique"), ("fromage", "bouffe-resto"), ("vin", "bouffe-resto"), ("café", "bouffe-resto"), ("boulangerie", "cuisine"), ("cabane à sucre", "bouffe-resto"), ("poutine", "bouffe-resto"), ("food truck", "bouffe-resto"), ("mixologie", "bouffe-resto"), ("couture", "arts"), ("crochet", "arts"), ("broderie", "arts"), ("thrift", "mode"), ("sneakers", "mode"), ("barbier", "beaute"), ("ongles", "beaute"), ("tatouage", "arts"), ("esthétique", "beaute"), ("organisation maison", "lifestyle"), ("minimalisme", "lifestyle"), ("van aménagé", "voyage"), ("chalet", "lifestyle"), ("astuces budget", "finance-affaires"), ("coupons", "finance-affaires"), ("frugal", "finance-affaires"), ("bourse dividendes", "finance-affaires"), ("intelligence artificielle", "tech"), ("cybersécurité", "tech"), ("impression 3d", "tech"), ("domotique", "tech"), ("drones", "tech"), # vague 4 : encore plus de passe-temps, sports, métiers et scènes ("escalade", "sport-fitness"), ("trail", "sport-fitness"), ("triathlon", "sport-fitness"), ("powerlifting", "sport-fitness"), ("arts martiaux", "sport-fitness"), ("jiu-jitsu", "sport-fitness"), ("karaté", "sport-fitness"), ("boxe thaï", "sport-fitness"), ("patinage", "sport-fitness"), ("ringuette", "sport-fitness"), ("curling", "sport-fitness"), ("cheerleading", "sport-fitness"), ("gymnastique", "sport-fitness"), ("natation", "sport-fitness"), ("plongée", "plein-air"), ("voile", "plein-air"), ("kayak de mer", "plein-air"), ("canot", "plein-air"), ("raquette", "plein-air"), ("ski de fond", "plein-air"), ("ski hors-piste", "plein-air"), ("escapade", "voyage"), ("roadtrip", "voyage"), ("croisière", "voyage"), ("aquariophilie", "autre"), ("reptiles", "autre"), ("chiens", "autre"), ("chats", "autre"), ("chevaux", "plein-air"), ("équitation", "plein-air"), ("fermette", "plein-air"), ("poules", "plein-air"), ("potager", "plein-air"), ("champignons", "plein-air"), ("mycologie", "plein-air"), ("herboristerie", "sante-mieux-etre"), ("zéro déchet", "lifestyle"), ("autosuffisance", "plein-air"), ("survivalisme", "plein-air"), ("vinyle", "musique"), ("audiophile", "tech"), ("home studio", "musique"), ("beatmaking", "musique"), ("production musicale", "musique"), ("ukulélé", "musique"), ("saxophone", "musique"), ("harmonica", "musique"), ("accordéon", "musique"), ("chant", "musique"), ("billard", "autre"), ("poker", "autre"), ("fléchettes", "autre"), ("airsoft", "autre"), ("paintball", "autre"), ("modélisme", "arts"), ("figurines", "arts"), ("bijoux", "arts"), ("savonnerie", "arts"), ("chandelles", "arts"), ("macramé", "arts"), ("scrapbooking", "arts"), ("philatélie", "education"), ("numismatique", "education"), ("antiquités", "autre"), ("brocante", "autre"), ("horlogerie", "arts"), ("restauration meubles", "arts"), ("ébénisterie", "arts"), ("charpenterie", "autre"), ("maçonnerie", "autre"), ("toiture", "autre"), ("mécanique diesel", "autre"), ("carrosserie", "autre"), ("détailing", "autre"), ("camion lourd", "autre"), ("agriculture laitière", "plein-air"), ("grandes cultures", "plein-air"), ("boucherie", "cuisine"), ("charcuterie", "cuisine"), ("fermentation", "cuisine"), ("kombucha", "cuisine"), ("cidre", "bouffe-resto"), ("hydromel", "bouffe-resto"), ("distillerie", "bouffe-resto"), ("torréfaction", "bouffe-resto"), ("sommelier", "bouffe-resto"), ("cocktails", "bouffe-resto"), ("orthopédagogie", "education"), ("orthophonie", "education"), ("tdah", "sante-mieux-etre"), ("autisme", "sante-mieux-etre"), ("kinésiologie", "sante-mieux-etre"), ("chiropratique", "sante-mieux-etre"), ("dermatologie", "beaute"), ("nutritionniste", "sante-mieux-etre"), ("sage-femme", "famille-parentalite"), ("doula", "famille-parentalite"), ("homeschooling", "famille-parentalite"), ("école à la maison", "famille-parentalite"), ("astuces ménage", "lifestyle"), ("décoration", "lifestyle"), ("design intérieur", "lifestyle"), ("aménagement paysager", "autre"), ("piscine", "autre"), ("spa", "autre"), ("courtier hypothécaire", "finance-affaires"), ("planificateur financier", "finance-affaires"), ("assurance", "finance-affaires"), ("fiscalité", "finance-affaires"), ("dropshipping", "finance-affaires"), ("commerce en ligne", "finance-affaires"), ("marketing numérique", "finance-affaires"), ("freelance", "finance-affaires"), ("linguistique", "education"), ("vulgarisation", "education"), ("archéologie", "education"), ("géographie", "education"), ("météo", "education"), ("aviation", "tech"), ("simulateur de vol", "gaming"), ("trains", "autre"), ("camping-car", "voyage"), ("vr roulotte", "voyage"), ("pêche blanche", "plein-air"), ("trappage", "plein-air"), ("taxidermie", "autre"), ("coutellerie", "arts"), ("armes à feu", "plein-air"), ("tir à l'arc", "plein-air"), ] for _h, _n in _HOBBIES: QUERIES.append((f"{_h} québec", _n)) QUERIES.append((f"{_h} québécois", _n)) def _walk_channels(node, out: list) -> None: if isinstance(node, dict): if "channelRenderer" in node: out.append(node["channelRenderer"]) for v in node.values(): _walk_channels(v, out) elif isinstance(node, list): for item in node: _walk_channels(item, out) def _walk_token(node) -> str | None: """Jeton de continuation (page suivante) n'importe où dans l'arbre JSON.""" if isinstance(node, dict): cmd = node.get("continuationCommand") if isinstance(cmd, dict) and cmd.get("token"): return cmd["token"] for v in node.values(): tok = _walk_token(v) if tok: return tok elif isinstance(node, list): for item in node: tok = _walk_token(item) if tok: return tok return None def parse_channels(html: str) -> list[dict]: """HTML de résultats → [{handle, channel_id, name, subs, description}].""" m = _YTID_RE.search(html) if not m: return [] try: data = json.loads(m.group(1)) except Exception: return [] return channels_from_data(data) def channels_from_data(data) -> list[dict]: """Arbre JSON (page 1 ou réponse de continuation) → liste de chaînes.""" renderers: list[dict] = [] _walk_channels(data, renderers) channels = [] for r in renderers: try: handle = ((r.get("navigationEndpoint") or {}).get("browseEndpoint") or {}).get("canonicalBaseUrl", "").lstrip("/") name = ((r.get("title") or {}).get("simpleText")) or "" desc = "".join(run.get("text", "") for run in ((r.get("descriptionSnippet") or {}).get("runs") or [])) subs_txt = ((r.get("videoCountText") or {}).get("simpleText")) or \ ((r.get("subscriberCountText") or {}).get("simpleText")) or "" # avatar « gratuit » : le renderer embarque les miniatures du profil # → meilleure résolution (URLs souvent protocol-relative « //yt3… ») thumbs = (r.get("thumbnail") or {}).get("thumbnails") or [] avatar = None if thumbs: avatar = max(thumbs, key=lambda t: t.get("width") or 0).get("url") if avatar and avatar.startswith("//"): avatar = "https:" + avatar channels.append({"handle": handle, "channel_id": r.get("channelId", ""), "name": name, "subs": parse_count(subs_txt), "description": desc, "avatar": avatar}) except Exception: continue return channels def is_quebec(text: str) -> bool: return bool(_QC_MARKERS.search(strip_accents(text or "").lower())) class YouTubeRechercheConnector(BaseConnector): source_id = "youtube-recherche" kind = "discovery" request_delay = 1.0 max_queries = 2500 max_pages_per_query = 4 # PROFONDEUR : pages de continuation (~20 chaînes/page) def _collect(self, channels: list[dict], niche: str, seen: set[str], creators: list[Creator]) -> None: for ch in channels: handle = ch["handle"].lstrip("@") key = handle or ch["channel_id"] if not key or key in seen: continue # filtre conservateur : marqueur QC requis dans titre+description if not is_quebec(f"{ch['name']} {ch['description']}"): continue seen.add(key) acc = account("youtube", handle or ch["channel_id"], "profil_source", followers=ch["subs"]) creators.append(Creator( source=self.source_id, external_id=slugify(ch["name"]), display_name=ch["name"], bio=ch["description"][:1200], niches=[niche], creator_type="youtubeur", avatar_url=ch.get("avatar"), platforms=[acc], source_ids=[f"{self.source_id}:{key}"], )) def fetch(self) -> list[Creator]: creators: list[Creator] = [] seen: set[str] = set() for query, niche in QUERIES[:self.max_queries]: try: html = self.get( SEARCH_URL, params={"search_query": query, "sp": "EgIQAg=="}, headers={"Accept-Language": "fr-CA,fr;q=0.9"}).text except Exception: continue m = _YTID_RE.search(html) if not m: continue try: data = json.loads(m.group(1)) except Exception: continue key_m, ver_m = _KEY_RE.search(html), _VER_RE.search(html) for page in range(self.max_pages_per_query): self._collect(channels_from_data(data), niche, seen, creators) token = _walk_token(data) if not token or not key_m or not ver_m \ or page == self.max_pages_per_query - 1: break try: # pages suivantes via l'API interne de la page publique data = self.post( INNERTUBE_URL, params={"key": key_m.group(1)}, json={"context": {"client": { "clientName": "WEB", "clientVersion": ver_m.group(1), "hl": "fr", "gl": "CA"}}, "continuation": token}, headers={"Accept-Language": "fr-CA,fr;q=0.9"}).json() except Exception: break return creators