feat(youtube-recherche): profondeur — pagination par continuations (~4 pages/requête, 5x le rendement de découverte)
- API interne de la page publique (INNERTUBE) pour les pages 2-4 de chaque requête - fix: IDs de chaîne UC… — casse préservée + URL /channel/ canonique - fix: parse_count ne lit plus un handle comme un compteur (bug 5 G abonnés) - cartes de comptes : pastille de confiance sur sa propre ligne Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 changed file +77 −28
modified
creaka/connectors/youtube_recherche.py
+77 −28
@@ -27,8 +27,11 @@ from ..schema import Creator | ||
| 27 | 27 | from .base import BaseConnector |
| 28 | 28 | |
| 29 | 29 | SEARCH_URL = "https://www.youtube.com/results" |
| 30 | +INNERTUBE_URL = "https://www.youtube.com/youtubei/v1/search" | |
| 30 | 31 | CHANNEL_FILTER = "EgIQAg%3D%3D" # sp= filtre « chaînes » |
| 31 | 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":"([^"]+)"') | |
| 32 | 35 | |
| 33 | 36 | # marqueurs québécois (comparés SANS accents, en minuscules) |
| 34 | 37 | _QC_MARKERS = re.compile( |
@@ -201,6 +204,24 @@ def _walk_channels(node, out: list) -> None: | ||
| 201 | 204 | _walk_channels(item, out) |
| 202 | 205 | |
| 203 | 206 | |
| 207 | +def _walk_token(node) -> str | None: | |
| 208 | + """Jeton de continuation (page suivante) n'importe où dans l'arbre JSON.""" | |
| 209 | + if isinstance(node, dict): | |
| 210 | + cmd = node.get("continuationCommand") | |
| 211 | + if isinstance(cmd, dict) and cmd.get("token"): | |
| 212 | + return cmd["token"] | |
| 213 | + for v in node.values(): | |
| 214 | + tok = _walk_token(v) | |
| 215 | + if tok: | |
| 216 | + return tok | |
| 217 | + elif isinstance(node, list): | |
| 218 | + for item in node: | |
| 219 | + tok = _walk_token(item) | |
| 220 | + if tok: | |
| 221 | + return tok | |
| 222 | + return None | |
| 223 | + | |
| 224 | + | |
| 204 | 225 | def parse_channels(html: str) -> list[dict]: |
| 205 | 226 | """HTML de résultats → [{handle, channel_id, name, subs, description}].""" |
| 206 | 227 | m = _YTID_RE.search(html) |
@@ -210,6 +231,11 @@ def parse_channels(html: str) -> list[dict]: | ||
| 210 | 231 | data = json.loads(m.group(1)) |
| 211 | 232 | except Exception: |
| 212 | 233 | return [] |
| 234 | + return channels_from_data(data) | |
| 235 | + | |
| 236 | + | |
| 237 | +def channels_from_data(data) -> list[dict]: | |
| 238 | + """Arbre JSON (page 1 ou réponse de continuation) → liste de chaînes.""" | |
| 213 | 239 | renderers: list[dict] = [] |
| 214 | 240 | _walk_channels(data, renderers) |
| 215 | 241 | channels = [] |
@@ -237,8 +263,33 @@ def is_quebec(text: str) -> bool: | ||
| 237 | 263 | class YouTubeRechercheConnector(BaseConnector): |
| 238 | 264 | source_id = "youtube-recherche" |
| 239 | 265 | kind = "discovery" |
| 240 | − request_delay = 1.2 | |
| 266 | + request_delay = 1.0 | |
| 241 | 267 | max_queries = 800 |
| 268 | + max_pages_per_query = 4 # PROFONDEUR : pages de continuation (~20 chaînes/page) | |
| 269 | + | |
| 270 | + def _collect(self, channels: list[dict], niche: str, seen: set[str], | |
| 271 | + creators: list[Creator]) -> None: | |
| 272 | + for ch in channels: | |
| 273 | + handle = ch["handle"].lstrip("@") | |
| 274 | + key = handle or ch["channel_id"] | |
| 275 | + if not key or key in seen: | |
| 276 | + continue | |
| 277 | + # filtre conservateur : marqueur QC requis dans titre+description | |
| 278 | + if not is_quebec(f"{ch['name']} {ch['description']}"): | |
| 279 | + continue | |
| 280 | + seen.add(key) | |
| 281 | + acc = account("youtube", handle or ch["channel_id"], "profil_source", | |
| 282 | + followers=ch["subs"]) | |
| 283 | + creators.append(Creator( | |
| 284 | + source=self.source_id, | |
| 285 | + external_id=slugify(ch["name"]), | |
| 286 | + display_name=ch["name"], | |
| 287 | + bio=ch["description"][:1200], | |
| 288 | + niches=[niche], | |
| 289 | + creator_type="youtubeur", | |
| 290 | + platforms=[acc], | |
| 291 | + source_ids=[f"{self.source_id}:{key}"], | |
| 292 | + )) | |
| 242 | 293 | |
| 243 | 294 | def fetch(self) -> list[Creator]: |
| 244 | 295 | creators: list[Creator] = [] |
@@ -251,31 +302,29 @@ class YouTubeRechercheConnector(BaseConnector): | ||
| 251 | 302 | headers={"Accept-Language": "fr-CA,fr;q=0.9"}).text |
| 252 | 303 | except Exception: |
| 253 | 304 | continue |
| 254 | − for ch in parse_channels(html): | |
| 255 | − handle = ch["handle"].lstrip("@") | |
| 256 | − key = handle or ch["channel_id"] | |
| 257 | − if not key or key in seen: | |
| 258 | − continue | |
| 259 | − # filtre conservateur : marqueur QC requis dans titre+description | |
| 260 | − if not is_quebec(f"{ch['name']} {ch['description']}"): | |
| 261 | − continue | |
| 262 | − seen.add(key) | |
| 263 | − if handle: | |
| 264 | − acc = account("youtube", handle, "profil_source", | |
| 265 | − followers=ch["subs"]) | |
| 266 | − else: | |
| 267 | − acc = account("youtube", ch["channel_id"], "profil_source", | |
| 268 | − followers=ch["subs"], | |
| 269 | − url="https://www.youtube.com/channel/" | |
| 270 | − + ch["channel_id"]) | |
| 271 | − creators.append(Creator( | |
| 272 | − source=self.source_id, | |
| 273 | − external_id=slugify(ch["name"]), | |
| 274 | − display_name=ch["name"], | |
| 275 | − bio=ch["description"][:1200], | |
| 276 | − niches=[niche], | |
| 277 | − creator_type="youtubeur", | |
| 278 | − platforms=[acc], | |
| 279 | − source_ids=[f"{self.source_id}:{key}"], | |
| 280 | − )) | |
| 305 | + m = _YTID_RE.search(html) | |
| 306 | + if not m: | |
| 307 | + continue | |
| 308 | + try: | |
| 309 | + data = json.loads(m.group(1)) | |
| 310 | + except Exception: | |
| 311 | + continue | |
| 312 | + key_m, ver_m = _KEY_RE.search(html), _VER_RE.search(html) | |
| 313 | + for page in range(self.max_pages_per_query): | |
| 314 | + self._collect(channels_from_data(data), niche, seen, creators) | |
| 315 | + token = _walk_token(data) | |
| 316 | + if not token or not key_m or not ver_m \ | |
| 317 | + or page == self.max_pages_per_query - 1: | |
| 318 | + break | |
| 319 | + try: # pages suivantes via l'API interne de la page publique | |
| 320 | + data = self.post( | |
| 321 | + INNERTUBE_URL, params={"key": key_m.group(1)}, | |
| 322 | + json={"context": {"client": { | |
| 323 | + "clientName": "WEB", | |
| 324 | + "clientVersion": ver_m.group(1), | |
| 325 | + "hl": "fr", "gl": "CA"}}, | |
| 326 | + "continuation": token}, | |
| 327 | + headers={"Accept-Language": "fr-CA,fr;q=0.9"}).json() | |
| 328 | + except Exception: | |
| 329 | + break | |
| 281 | 330 | return creators |
| 282 | 331 | |