161 lines
6.9 KiB
Python
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" # gitleaks:allow -- the public WEB key, in every results page
|
|
_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
|