"""Zero-quota YouTube search via InnerTube (the website's own youtubei/v1/search endpoint). Live YouTube search **without spending any YouTube Data API quota**: instead of search.list (100 units per page) we POST to YouTube's internal InnerTube endpoint — the same one the website calls — and read the result list straight out of the JSON. The returned video ids then go through the normal enrich path (videos.list, 1 unit / 50), exactly as the official search does, so the search itself is effectively free and only the (cheap, shared) enrichment is charged. The SOCS=CAI consent-skip cookie is used like the Shorts probe so a server-side request isn't bounced to consent.youtube.com. This deliberately scrapes an undocumented endpoint: if YouTube changes the JSON shape or blocks the host, an admin can flip ``search_source`` back to ``api``. The returned page mirrors :meth:`YouTubeClient.search_videos` exactly (``{"items": [...], "next_page_token": str | None}``) so the route is source-agnostic — the only difference is the cursor is an InnerTube *continuation token*, not a Data API pageToken. ``published_at`` is left ``None`` (the scrape only exposes a relative time like "3 years ago"); the real date is filled by the videos.list enrich. ``live_broadcast`` is reported as ``"none"`` and live/upcoming filtering happens post-enrich via ``live_status`` (same as the API path).""" import re import httpx _SEARCH_URL = "https://www.youtube.com/youtubei/v1/search" _RESULTS_URL = "https://www.youtube.com/results" # Public WEB InnerTube key + a recent client version. These are also embedded in every results # page; we fetch the live pair once and cache it, falling back to these constants. _FALLBACK_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8" _FALLBACK_CLIENT_VERSION = "2.20240101.00.00" # sp / params value selecting the "type: video" search filter (base64 protobuf EgIQAQ==). _TYPE_VIDEO = "EgIQAQ==" _HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", # SOCS skips YouTube's cookie-consent interstitial (otherwise every server-side request is # redirected to consent.youtube.com). "Cookie": "SOCS=CAI", "Accept-Language": "en-US,en;q=0.9", } _KEY_RE = re.compile(r'"INNERTUBE_API_KEY":"([^"]+)"') _VER_RE = re.compile(r'"INNERTUBE_CLIENT_VERSION":"([^"]+)"') # Cached (api_key, client_version) extracted from a results page; refreshed on demand. _config: tuple[str, str] | None = None class ScrapeError(Exception): """A scrape-based search could not be completed (network, blocked, or unexpected shape).""" def _runs_text(node: dict | None) -> str | None: if not node: return None if node.get("simpleText"): return node["simpleText"] runs = node.get("runs") or [] if runs: return "".join(r.get("text", "") for r in runs) return None def _get_config(client: httpx.Client) -> tuple[str, str]: """The current InnerTube (api_key, client_version), cached after the first fetch.""" global _config if _config is not None: return _config try: resp = client.get(_RESULTS_URL, params={"search_query": "music"}) html = resp.text key = _KEY_RE.search(html) ver = _VER_RE.search(html) _config = ( key.group(1) if key else _FALLBACK_KEY, ver.group(1) if ver else _FALLBACK_CLIENT_VERSION, ) except httpx.HTTPError: _config = (_FALLBACK_KEY, _FALLBACK_CLIENT_VERSION) return _config def _video_item(vr: dict) -> dict | None: """Flatten a YouTube `videoRenderer` into the same shape as YouTubeClient.search_videos.""" vid = vr.get("videoId") if not vid: return None owner = vr.get("ownerText") or vr.get("longBylineText") or {} runs = owner.get("runs") or [] channel_id = channel_title = None if runs: channel_title = runs[0].get("text") channel_id = ( runs[0] .get("navigationEndpoint", {}) .get("browseEndpoint", {}) .get("browseId") ) thumbs = (vr.get("thumbnail") or {}).get("thumbnails") or [] return { "id": vid, "channel_id": channel_id, "channel_title": channel_title, "title": _runs_text(vr.get("title")), "published_at": None, # scrape exposes only a relative time; enrich fills the real date "thumbnail_url": thumbs[-1]["url"] if thumbs else None, "live_broadcast": "none", # live/upcoming filtered post-enrich via live_status } def _collect_videos(obj, out: list[dict], seen: set[str]) -> None: """Walk the response collecting videoRenderer items in document (relevance) order. Only `videoRenderer` is taken, so channels/playlists/Shorts shelves (reelItemRenderer) are naturally excluded.""" if isinstance(obj, dict): vr = obj.get("videoRenderer") if isinstance(vr, dict): item = _video_item(vr) if item and item["id"] not in seen: seen.add(item["id"]) out.append(item) for v in obj.values(): _collect_videos(v, out, seen) elif isinstance(obj, list): for v in obj: _collect_videos(v, out, seen) def _find_continuation(obj) -> str | None: """First continuationItemRenderer token (the 'load more' cursor), or None at the end.""" if isinstance(obj, dict): cir = obj.get("continuationItemRenderer") if isinstance(cir, dict): tok = ( cir.get("continuationEndpoint", {}) .get("continuationCommand", {}) .get("token") ) if tok: return tok for v in obj.values(): tok = _find_continuation(v) if tok: return tok elif isinstance(obj, list): for v in obj: tok = _find_continuation(v) if tok: return tok return None def search(q: str, continuation: str | None = None) -> dict: """One page of zero-quota YouTube search results. `continuation` is the InnerTube continuation token from a previous page (None for the first page). Returns ``{"items": [...], "next_page_token": str | None}`` — the same shape as YouTubeClient.search_videos so the route handles both sources identically.""" with httpx.Client(timeout=20.0, headers=_HEADERS) as client: api_key, client_version = _get_config(client) context = { "client": { "clientName": "WEB", "clientVersion": client_version, "hl": "en", "gl": "US", } } body: dict = {"context": context} if continuation: body["continuation"] = continuation else: body["query"] = q body["params"] = _TYPE_VIDEO try: resp = client.post(_SEARCH_URL, params={"key": api_key}, json=body) resp.raise_for_status() data = resp.json() except httpx.HTTPError as exc: raise ScrapeError(f"InnerTube request failed: {exc}") from exc except ValueError as exc: # non-JSON body (blocked / consent / captcha) raise ScrapeError("InnerTube returned a non-JSON response") from exc items: list[dict] = [] _collect_videos(data, items, set()) return {"items": items, "next_page_token": _find_continuation(data)}