# ============================================================================== # Author: Simon-Pierre Boucher # File: src/main.py (ka-youtube) # Desc: Chaînes YouTube ULTRA DÉTAILLÉES SANS clé API via le JSON ytInitialData # de la page /videos : abonnés, nb de vidéos, description, avatar, # BANNIÈRE, badge vérifié + vidéos récentes avec MINIATURES (vues, durée, # date relative) et agrégats — plus un 2e passage /about (vues totales de # la chaîne, date de création, pays, LIENS EXTERNES auto-déclarés). # Accepte @handle, handle nu, ou id de chaîne (UC…). # ============================================================================== from __future__ import annotations import asyncio import json import re import urllib.parse from apify import Actor from .net import Fetcher _INITIAL_RE = re.compile(r"var ytInitialData\s*=\s*(\{.*?\});", re.S) _COUNT_RE = re.compile(r"([\d][\d\s  .,]*)\s*([KkMB]?)") def _url_for(handle: str) -> str: h = urllib.parse.unquote(handle).strip() if h.startswith("UC") and len(h) == 24: return f"https://www.youtube.com/channel/{h}/videos" if not h.startswith("@"): h = "@" + h return f"https://www.youtube.com/{urllib.parse.quote(h)}/videos" def parse_text_count(text: str) -> int | None: """« 1.23M subscribers », « 12 345 vues », « 4,5 k » → entier.""" if not text: return None m = _COUNT_RE.search(str(text)) if not m: return None num = re.sub(r"[\s  ]", "", m.group(1)) unit = (m.group(2) or "").upper() if unit: num = num.replace(",", ".") if num.count(".") > 1: num = num.replace(".", "", num.count(".") - 1) try: val = float(num) except ValueError: return None return int(val * {"K": 1e3, "M": 1e6, "B": 1e9}[unit]) num = re.sub(r"[.,]", "", num) return int(num) if num.isdigit() else None def _walk(node, key: str): """Premier objet portant `key` dans l'arbre ytInitialData.""" if isinstance(node, dict): if key in node: return node[key] for v in node.values(): hit = _walk(v, key) if hit is not None: return hit elif isinstance(node, list): for v in node: hit = _walk(v, key) if hit is not None: return hit return None def _texts(node) -> list[str]: """Toutes les chaînes `content`/`simpleText`/`text` sous un nœud.""" out: list[str] = [] def rec(n): if isinstance(n, dict): for k in ("content", "simpleText", "text"): if isinstance(n.get(k), str): out.append(n[k]) for v in n.values(): rec(v) elif isinstance(n, list): for v in n: rec(v) rec(node) return out def _lockup_video(lv: dict) -> dict | None: """Carte vidéo moderne (lockupViewModel) → dict vidéo standardisé.""" if lv.get("contentType") != "LOCKUP_CONTENT_TYPE_VIDEO": return None vid = lv.get("contentId") if not vid: return None md = lv.get("metadata") or {} title = ((md.get("lockupMetadataViewModel") or {}).get("title") or {}) \ .get("content") strings = _texts(md) views = next((parse_text_count(s) for s in strings if "view" in s.lower() or "vue" in s.lower()), None) published = next((s for s in strings if "ago" in s.lower() or "il y a" in s.lower()), None) overlays = _texts(lv.get("contentImage") or {}) duration = next((s for s in overlays if re.fullmatch(r"\d?\d:\d\d(:\d\d)?", s.strip())), None) return { "id": vid, "url": f"https://www.youtube.com/watch?v={vid}", "title": (title or "")[:200], "views": views, "published": published, "duration": duration, "thumbnail": f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg", } def _collect_videos(data: dict, cap: int = 15) -> list[dict]: out: list[dict] = [] def rec(node): if len(out) >= cap: return if isinstance(node, dict): lv = node.get("lockupViewModel") if isinstance(lv, dict): v = _lockup_video(lv) if v: out.append(v) return vr = node.get("videoRenderer") or node.get("gridVideoRenderer") if vr and vr.get("videoId"): out.append({ "id": vr["videoId"], "url": f"https://www.youtube.com/watch?v={vr['videoId']}", "title": "".join( r.get("text", "") for r in (vr.get("title") or {}).get("runs") or [])[:200], "views": parse_text_count( (vr.get("viewCountText") or {}).get("simpleText")), "published": ((vr.get("publishedTimeText") or {}) .get("simpleText")), "duration": ((vr.get("lengthText") or {}) .get("simpleText")), "thumbnail": (f"https://i.ytimg.com/vi/{vr['videoId']}" f"/hqdefault.jpg"), }) return for v in node.values(): rec(v) elif isinstance(node, list): for v in node: rec(v) rec(data) return out def _s(v): """ViewModel : champ tantôt chaîne nue, tantôt {content: "..."}.""" return v.get("content") if isinstance(v, dict) else v _REL_RE = re.compile( r"(\d+)\s*(hour|day|week|month|year|heure|jour|semaine|mois|an)", re.I) _REL_DAYS = {"hour": 1 / 24, "heure": 1 / 24, "day": 1, "jour": 1, "week": 7, "semaine": 7, "month": 30.44, "mois": 30.44, "year": 365.25, "an": 365.25} def _rel_days(published: str | None) -> float | None: """« 3 weeks ago » / « il y a 2 mois » → ancienneté approx. en jours.""" m = _REL_RE.search(published or "") if not m: return None unit = m.group(2).lower() unit = next((k for k in _REL_DAYS if unit.startswith(k)), None) return float(m.group(1)) * _REL_DAYS[unit] if unit else None def _cadence(vids: list[dict]) -> float | None: """Vidéos/mois estimées d'après les dates relatives (approximatif).""" ages = sorted(a for a in (_rel_days(v.get("published")) for v in vids) if a is not None) if len(ages) < 3 or ages[-1] <= ages[0]: return None return round((len(ages) - 1) / ((ages[-1] - ages[0]) / 30.44), 2) def _banner_of(data: dict) -> str | None: """Bannière de chaîne : nouvel en-tête (imageBannerViewModel) ou ancien.""" b = _walk(data, "imageBannerViewModel") if isinstance(b, dict): srcs = (b.get("image") or {}).get("sources") or [] if srcs and srcs[-1].get("url"): return srcs[-1]["url"] thumbs = (((_walk(data, "c4TabbedHeaderRenderer") or {}).get("banner") or {}).get("thumbnails")) or [] return thumbs[-1].get("url") if thumbs else None def _about_of(data: dict) -> dict: """Panneau « À propos » (/about) → vues totales, date, pays, liens.""" acv = _walk(data, "aboutChannelViewModel") or {} if not acv: return {} links = [] for entry in (acv.get("links") or []): lv = entry.get("channelExternalLinkViewModel") or {} url = _s(lv.get("link")) if url: links.append({"title": _s(lv.get("title")), "url": url}) return { "total_views": parse_text_count(_s(acv.get("viewCountText"))), "joined_date": _s(acv.get("joinedDateText")), "country": _s(acv.get("country")), "external_links": links[:10], } async def main() -> None: async with Actor: inp = await Actor.get_input() or {} fetch_about = bool(inp.get("fetchAbout", True)) usernames = [u.strip() for u in (inp.get("usernames") or []) if u and u.strip()] proxy = await Actor.create_proxy_configuration( actor_proxy_input=inp.get("proxyConfiguration")) fetcher = Fetcher(proxy, delay=float(inp.get("requestDelay") or 1)) sem = asyncio.Semaphore(int(inp.get("concurrency") or 3)) async def one(u: str) -> None: async with sem: miss = {"kind": "profile", "platform": "youtube", "username": u, "found": False} try: resp = await fetcher.get( _url_for(u), session_id=u, headers={"Accept-Language": "en-US,en;q=0.9"}) except Exception as exc: await Actor.push_data({**miss, "error": str(exc)[:200]}) return m = _INITIAL_RE.search(resp.text or "") if resp.status_code != 200 or not m: await Actor.push_data( {**miss, "error": f"http_{resp.status_code}"}) return try: data = json.loads(m.group(1)) except Exception: await Actor.push_data({**miss, "error": "bad_json"}) return meta = _walk(data, "channelMetadataRenderer") or {} if not meta.get("externalId"): await Actor.push_data({**miss, "error": "no_channel"}) return # compteurs : le nouvel en-tête n'a plus subscriberCountText ; # on lit les chaînes « N subscribers » / « N videos » du header header = _walk(data, "pageHeaderViewModel") or {} header_txt = _texts(header) or _texts( _walk(data, "c4TabbedHeaderRenderer") or {}) sub_text = next((t for t in header_txt if "subscriber" in t.lower() or "abonné" in t.lower()), "") vid_text = next((t for t in header_txt if ("video" in t.lower() or "vidéo" in t.lower()) and any(c.isdigit() for c in t)), "") if not sub_text: # ancien en-tête sub_text = ((_walk(data, "subscriberCountText") or {}) .get("simpleText") or "") subs = parse_text_count(sub_text) videos_count = parse_text_count(vid_text) vids = _collect_videos(data) views = [v["views"] for v in vids if isinstance(v["views"], int)] text = resp.text or "" is_verified = True if ( '"BADGE_STYLE_TYPE_VERIFIED"' in text or '"BADGE_STYLE_TYPE_VERIFIED_ARTIST"' in text or '"OFFICIAL_ARTIST_BADGE"' in text) else None about: dict = {} if fetch_about: try: resp_a = await fetcher.get( _url_for(u).replace("/videos", "/about"), session_id=f"{u}a", headers={"Accept-Language": "en-US,en;q=0.9"}) ma = _INITIAL_RE.search(resp_a.text or "") if ma: about = _about_of(json.loads(ma.group(1))) except Exception as exc: Actor.log.warning(f"/about {u} : {exc}") await Actor.push_data({ "kind": "profile", "platform": "youtube", "found": True, "username": u, "channel_id": meta.get("externalId"), "full_name": meta.get("title"), "biography": (meta.get("description") or "")[:1000], "followers": subs, "videos_count": videos_count, "is_family_safe": meta.get("isFamilySafe"), "is_verified": is_verified, "keywords": (meta.get("keywords") or "")[:300] or None, "country": about.get("country") or _walk(data, "country"), "canonical_url": meta.get("vanityChannelUrl"), "avatar": (((meta.get("avatar") or {}).get("thumbnails") or [{}])[-1].get("url")), "banner": _banner_of(data), "total_views": about.get("total_views"), "joined_date": about.get("joined_date"), "external_links": about.get("external_links") or [], "avg_views": (round(sum(views) / len(views)) if views else None), "videos_per_month": _cadence(vids), "is_live_now": ('"style":"LIVE"' in text or '"label":"LIVE"' in text) or None, "has_shorts": ('"title":"Shorts"' in text) or None, "top_video": (max( (v for v in vids if isinstance(v["views"], int)), key=lambda v: v["views"], default=None)), "last_video_published": (vids[0]["published"] if vids else None), "recent_videos": vids, }) await asyncio.gather(*[one(u) for u in usernames]) Actor.log.info(f"terminé : {len(usernames)} chaînes")