Files
siftlode/backend/app/youtube/innertube.py
T
peter d53729a63d fix(youtube): a related page is empty only on YouTube's own word
The watch frame alone read as "nothing related" — so a related column YouTube moved, or a list
drawn by renderers this does not read, answered an empty list instead of an error. Now only the
"isn't available" promo YouTube draws for a video it no longer has is an empty page; anything else
it cannot read is a ScrapeError.

The flattening of a video renderer and the read of the continuation token are one each, in
innertube, for the search and the related scrape.
2026-10-25 00:32:27 +02:00

161 lines
6.9 KiB
Python

"""The website's own InnerTube API (`youtubei/v1/*`), read the way the website reads it — no quota.
The zero-quota search (`search_scrape`) and the related videos (`related_scrape`) both POST here with
the public WEB key and a client version, read out of a live results page. This deliberately calls an
undocumented endpoint: if YouTube changes the JSON shape or blocks the host, each caller says so as a
`ScrapeError`, never as an empty answer.
The SOCS=CAI consent-skip cookie is sent like the Shorts probe's, so a server-side request isn't
bounced to consent.youtube.com."""
import re
import httpx
from app import logs
log = logs.get_logger(__name__)
_API_URL = "https://www.youtube.com/youtubei/v1/"
_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"
_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):
"""An InnerTube read could not be completed (network, blocked, or unexpected shape)."""
def runs_text(node: dict | None) -> str | None:
"""The text of an InnerTube text node: a `simpleText`, or its `runs` joined."""
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 scraped_video(
vid: str, channel_id: object, channel_title: object, title: object, thumbnails: object
) -> dict:
"""One scraped video in the shape `YouTubeClient.search_videos` answers, so a page of either
source is ingested the same way. `published_at` is None (the website shows only a relative
"3 years ago"; the enrich fills the real date) and `live_broadcast` "none" (live/upcoming are
judged after the enrich, by `live_status`). The largest thumbnail is the last."""
thumbs = thumbnails if isinstance(thumbnails, list) else []
last = thumbs[-1] if thumbs else None
return {
"id": vid,
"channel_id": channel_id if isinstance(channel_id, str) and channel_id else None,
"channel_title": channel_title if isinstance(channel_title, str) else None,
"title": title if isinstance(title, str) else None,
"published_at": None,
"thumbnail_url": last.get("url") if isinstance(last, dict) else None,
"live_broadcast": "none",
}
def video_renderer(node: dict) -> dict | None:
"""A `videoRenderer` (search) or `compactVideoRenderer` (the older related column) flattened —
the two draw a video the same way. None without a video id; the channel is None when the byline
names none."""
vid = node.get("videoId")
if not isinstance(vid, str) or not vid:
return None
owner = node.get("ownerText") or node.get("longBylineText") or {}
runs = owner.get("runs") if isinstance(owner, dict) else None
first = runs[0] if isinstance(runs, list) and runs and isinstance(runs[0], dict) else {}
endpoint = (first.get("navigationEndpoint") or {}).get("browseEndpoint") or {}
return scraped_video(
vid,
endpoint.get("browseId"),
first.get("text"),
runs_text(node.get("title")),
(node.get("thumbnail") or {}).get("thumbnails"),
)
def continuation_token(node: object) -> str | None:
"""The "load more" cursor of a `continuationItemRenderer`, or None when `node` is not one."""
if not isinstance(node, dict):
return None
command = (node.get("continuationEndpoint") or {}).get("continuationCommand") or {}
token = command.get("token")
return token if isinstance(token, str) and token else None
def _get_config(client: httpx.Client) -> tuple[str, str]:
"""The current InnerTube (api_key, client_version), cached after the first fetch that FOUND them.
The fallback pair is never cached: it used to be, so one failed fetch (or a consent page with no
keys on it) at the wrong moment pinned every later search to a hard-coded key and version for
the life of the process — and when those went stale, search returned nothing, with no error
anywhere. Now the next call simply tries the live pair again."""
global _config
if _config is not None:
return _config
try:
html = client.get(_RESULTS_URL, params={"search_query": "music"}).text
except httpx.HTTPError as exc:
log.warning(
"the InnerTube config could not be fetched; falling back to the pinned key",
extra=logs.event("youtube.scrape.config_fallback", reason=type(exc).__name__),
)
return (_FALLBACK_KEY, _FALLBACK_CLIENT_VERSION)
key = _KEY_RE.search(html)
ver = _VER_RE.search(html)
if key and ver:
_config = (key.group(1), ver.group(1))
return _config
log.warning(
"the results page carried no InnerTube config; falling back to the pinned key",
extra=logs.event("youtube.scrape.config_fallback", reason="not_on_page"),
)
return (
key.group(1) if key else _FALLBACK_KEY,
ver.group(1) if ver else _FALLBACK_CLIENT_VERSION,
)
def post(endpoint: str, body: dict, *, hl: str = "en", gl: str = "US") -> dict:
"""POST `body` to `youtubei/v1/<endpoint>` as the WEB client in the `hl`/`gl` locale and return
the decoded JSON object. A network failure, an HTTP error or a body that is not a JSON object (a
block, consent or captcha page) raises `ScrapeError`; the caller judges the shape."""
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": hl, "gl": gl}
}
try:
resp = client.post(
_API_URL + endpoint, params={"key": api_key}, json={"context": context, **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
if not isinstance(data, dict):
raise ScrapeError("InnerTube returned an unexpected shape")
return data