mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-08 22:22:17 +02:00
Move app/scrapers -> app/proprietary/scrapers/youtube to sit alongside the existing proprietary web_crawler/platforms namespace, updating all external imports (routes, tests, e2e script, README). Internal imports were relative so are unchanged. Also parallelize playlist per-video resolution: page video ids sequentially, then resolve the heavy watch-page fetches concurrently via fan_out (~150 videos ~70s, down from a few minutes). Items stream in completion order; sort by the order field for playlist order. Co-authored-by: Cursor <cursoragent@cursor.com>
562 lines
19 KiB
Python
562 lines
19 KiB
Python
"""Orchestrator for the YouTube scraper.
|
||
|
||
The core is the async generator :func:`iter_youtube` (unbounded / continuation
|
||
paged); :func:`scrape_youtube` is a thin collector with a caller-supplied
|
||
``limit`` guard. Per-type counters (regular / shorts / streams) are applied
|
||
independently per search term and per channel, matching Apify semantics. Any cap
|
||
is caller policy, never baked into flow logic.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import logging
|
||
from collections.abc import AsyncIterator
|
||
from typing import Any
|
||
from urllib.parse import quote
|
||
|
||
from .innertube import (
|
||
INNERTUBE_BROWSE_URL,
|
||
INNERTUBE_NEXT_URL,
|
||
INNERTUBE_PUBLIC_API_KEY,
|
||
INNERTUBE_SEARCH_URL,
|
||
bind_proxy_holder,
|
||
build_innertube_payload,
|
||
fetch_html,
|
||
open_proxy_holder,
|
||
post_innertube,
|
||
)
|
||
from .parsers import (
|
||
channel_about_tokens,
|
||
extract_yt_initial_data,
|
||
find_first,
|
||
parse_channel_about,
|
||
parse_channel_metadata,
|
||
parse_channel_shorts,
|
||
parse_channel_sort_tokens,
|
||
parse_channel_videos,
|
||
parse_playlist_video_ids,
|
||
parse_search_response,
|
||
parse_translation,
|
||
parse_video_page,
|
||
)
|
||
from .schemas import VideoItem, YouTubeScrapeInput
|
||
from .search_filters import build_search_params
|
||
from .subtitles import fetch_subtitles
|
||
from .url_resolver import ResolvedUrl, resolve_url
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
_SORT_LABELS = {"NEWEST": "Latest", "POPULAR": "Popular", "OLDEST": "Oldest"}
|
||
|
||
# Independent jobs (one per startUrl / search query / video) run concurrently on
|
||
# a pool of warm proxy sessions (sticky IPs). A ramp probe on the gateway ran 64
|
||
# parallel flows with zero failures, so the proxy is not the ceiling; 16 workers
|
||
# saturate typical job counts while leaving gateway headroom for other callers.
|
||
_FANOUT_CONCURRENCY = 16
|
||
|
||
|
||
async def fan_out(
|
||
jobs: list[AsyncIterator[dict[str, Any]]], *, concurrency: int = _FANOUT_CONCURRENCY
|
||
) -> AsyncIterator[dict[str, Any]]:
|
||
"""Stream items from independent async-iterator jobs via a warm worker pool.
|
||
|
||
Each worker opens ONE proxy session and reuses it across the sequential jobs
|
||
it pulls, so only the first job per worker pays the ~2s proxy TCP+TLS
|
||
handshake. A bad job yields nothing rather than aborting the batch; results
|
||
stream out as each job finishes. Workers are cancelled if the consumer stops
|
||
early (e.g. the collector hits its limit).
|
||
"""
|
||
if not jobs:
|
||
return
|
||
job_queue: asyncio.Queue[AsyncIterator[dict[str, Any]]] = asyncio.Queue()
|
||
for job in jobs:
|
||
job_queue.put_nowait(job)
|
||
results: asyncio.Queue[list[dict[str, Any]]] = asyncio.Queue()
|
||
|
||
async def worker() -> None:
|
||
holder = None
|
||
try:
|
||
holder = await open_proxy_holder()
|
||
except Exception as e: # no session: jobs still run via one-shot fetches
|
||
logger.warning("[youtube] proxy session open failed: %s", e)
|
||
try:
|
||
while True:
|
||
try:
|
||
job = job_queue.get_nowait()
|
||
except asyncio.QueueEmpty:
|
||
return
|
||
items: list[dict[str, Any]] = []
|
||
try:
|
||
if holder is not None:
|
||
async with bind_proxy_holder(holder):
|
||
items = [item async for item in job]
|
||
else:
|
||
items = [item async for item in job]
|
||
except Exception as e: # one bad video/URL must not kill the run
|
||
logger.warning("[youtube] fan-out job failed: %s", e)
|
||
await results.put(items)
|
||
finally:
|
||
if holder is not None:
|
||
await holder.close()
|
||
|
||
tasks = [
|
||
asyncio.create_task(worker()) for _ in range(min(concurrency, len(jobs)))
|
||
]
|
||
try:
|
||
for _ in range(len(jobs)):
|
||
for item in await results.get():
|
||
yield item
|
||
finally:
|
||
for task in tasks:
|
||
if not task.done():
|
||
task.cancel()
|
||
# Await cancellation so each worker's finally closes its session before
|
||
# we return — no leaked keep-alive connections when the consumer stops
|
||
# early (e.g. the collector hit its limit).
|
||
await asyncio.gather(*tasks, return_exceptions=True)
|
||
|
||
|
||
async def _post(url: str, payload: dict[str, Any]) -> dict[str, Any] | None:
|
||
"""POST to InnerTube, retrying with the public web key if keyless fails.
|
||
|
||
ponytail: retries with the key only when the keyless call returns nothing;
|
||
could remember which one worked to avoid the extra request.
|
||
"""
|
||
data = await post_innertube(url, payload)
|
||
if data is None:
|
||
data = await post_innertube(url, payload, api_key=INNERTUBE_PUBLIC_API_KEY)
|
||
return data
|
||
|
||
|
||
async def _finalize(
|
||
partial: dict[str, Any],
|
||
*,
|
||
input_model: YouTubeScrapeInput,
|
||
source_input: str | None,
|
||
from_url: str | None,
|
||
order: int,
|
||
content_type: str,
|
||
) -> dict[str, Any]:
|
||
item = VideoItem(**partial)
|
||
item.type = content_type # type: ignore[assignment]
|
||
item.input = source_input
|
||
item.fromYTUrl = from_url
|
||
item.order = order
|
||
if input_model.downloadSubtitles and item.id:
|
||
item.subtitles = await fetch_subtitles(
|
||
item.id,
|
||
language=input_model.subtitlesLanguage,
|
||
fmt=input_model.subtitlesFormat,
|
||
prefer_generated=input_model.preferAutoGeneratedSubtitles,
|
||
)
|
||
# translatedTitle/Text: one extra /next in the requested language. ponytail:
|
||
# gated on a non-English subtitlesLanguage so default runs pay nothing; costs
|
||
# one request per item when a translation language is set.
|
||
lang = input_model.subtitlesLanguage
|
||
if item.id and lang and lang != "en":
|
||
data = await _post(
|
||
INNERTUBE_NEXT_URL, build_innertube_payload(video_id=item.id, hl=lang)
|
||
)
|
||
if data:
|
||
item.translatedTitle, item.translatedText = parse_translation(data)
|
||
return item.to_output()
|
||
|
||
|
||
async def _video_flow(
|
||
video_id: str,
|
||
*,
|
||
input_model: YouTubeScrapeInput,
|
||
source_input: str | None,
|
||
from_url: str | None,
|
||
order: int,
|
||
content_type: str,
|
||
) -> AsyncIterator[dict[str, Any]]:
|
||
url = f"https://www.youtube.com/watch?v={video_id}"
|
||
html = await fetch_html(url)
|
||
if not html:
|
||
return
|
||
partial = parse_video_page(html)
|
||
if not partial:
|
||
return
|
||
yield await _finalize(
|
||
partial,
|
||
input_model=input_model,
|
||
source_input=source_input,
|
||
from_url=from_url,
|
||
order=order,
|
||
content_type=content_type,
|
||
)
|
||
|
||
|
||
async def _search_flow(
|
||
query: str,
|
||
*,
|
||
input_model: YouTubeScrapeInput,
|
||
source_input: str,
|
||
) -> AsyncIterator[dict[str, Any]]:
|
||
limit = input_model.maxResults
|
||
if limit <= 0:
|
||
return
|
||
from_url = f"https://www.youtube.com/results?search_query={quote(query)}"
|
||
params = build_search_params(input_model)
|
||
payload = build_innertube_payload(search_query=query, search_params=params)
|
||
data = await _post(INNERTUBE_SEARCH_URL, payload)
|
||
|
||
order = 0
|
||
while data:
|
||
items, token = parse_search_response(data)
|
||
for it in items:
|
||
if order >= limit:
|
||
return
|
||
yield await _finalize(
|
||
it,
|
||
input_model=input_model,
|
||
source_input=source_input,
|
||
from_url=from_url,
|
||
order=order,
|
||
content_type="video",
|
||
)
|
||
order += 1
|
||
if not token or order >= limit:
|
||
return
|
||
data = await _post(
|
||
INNERTUBE_SEARCH_URL, build_innertube_payload(continuation_token=token)
|
||
)
|
||
|
||
|
||
def _channel_tab_url(handle: str, tab: str) -> str:
|
||
if handle.startswith("UC") and len(handle) > 10:
|
||
return f"https://www.youtube.com/channel/{handle}/{tab}"
|
||
return f"https://www.youtube.com/@{handle}/{tab}"
|
||
|
||
|
||
# tab path -> (content type, list parser). Streams share the video lockup shape.
|
||
_CHANNEL_TABS = {
|
||
"videos": ("video", parse_channel_videos),
|
||
"shorts": ("shorts", parse_channel_shorts),
|
||
"streams": ("stream", parse_channel_videos),
|
||
}
|
||
|
||
|
||
async def _fetch_channel_about(initial: dict) -> dict[str, Any]:
|
||
"""One ``/browse`` call for the About panel (deep channel fields).
|
||
|
||
Panels are unlabeled, so try each engagement token and keep the first that
|
||
returns an ``aboutChannelViewModel``. ponytail: worst case is one extra
|
||
no-op browse before the hit; a labeled-panel signal would remove it.
|
||
"""
|
||
for token in channel_about_tokens(initial):
|
||
data = await _post(
|
||
INNERTUBE_BROWSE_URL, build_innertube_payload(continuation_token=token)
|
||
)
|
||
about = find_first(data, "aboutChannelViewModel") if data else None
|
||
if about:
|
||
return parse_channel_about(about)
|
||
return {}
|
||
|
||
|
||
def _published_date(text: str | None):
|
||
"""Parse a relative/absolute time string to a ``date`` (best-effort).
|
||
|
||
ponytail: channel list pages only expose coarse relative times ("2 years
|
||
ago"), so the ``oldestPostDate`` cutoff is day-accurate at best.
|
||
"""
|
||
if not text:
|
||
return None
|
||
import dateparser
|
||
|
||
dt = dateparser.parse(text)
|
||
return dt.date() if dt else None
|
||
|
||
|
||
async def _channel_tab_flow(
|
||
handle: str,
|
||
tab: str,
|
||
*,
|
||
limit: int,
|
||
input_model: YouTubeScrapeInput,
|
||
source_input: str,
|
||
channel_meta: dict[str, Any],
|
||
initial: dict | None = None,
|
||
cutoff=None,
|
||
) -> AsyncIterator[dict[str, Any]]:
|
||
"""Page one channel tab (videos/shorts/streams) up to ``limit`` items.
|
||
|
||
Videos honor ``sortVideosBy`` via the sort chips (re-fetch sorted from the
|
||
start); shorts/streams page straight from the seed's first page + its
|
||
continuation, since those tabs don't expose the same sort chips. ``initial``
|
||
may be prefetched (videos tab) to avoid re-downloading the seed.
|
||
"""
|
||
if limit <= 0:
|
||
return
|
||
content_type, parse_fn = _CHANNEL_TABS[tab]
|
||
from_url = _channel_tab_url(handle, tab)
|
||
if initial is None:
|
||
seed_html = await fetch_html(from_url)
|
||
if not seed_html:
|
||
return
|
||
initial = extract_yt_initial_data(seed_html)
|
||
if not initial:
|
||
return
|
||
|
||
# Videos: prefer a sort chip token (fetches page 1 sorted). Otherwise parse
|
||
# the seed's first page directly and follow its continuation.
|
||
items: list[dict[str, Any]] = []
|
||
token: str | None = None
|
||
if tab == "videos":
|
||
tokens = parse_channel_sort_tokens(initial)
|
||
label = _SORT_LABELS.get(input_model.sortVideosBy or "NEWEST", "Latest")
|
||
token = tokens.get(label) or next(iter(tokens.values()), None)
|
||
if token is None:
|
||
items, token = parse_fn(initial)
|
||
|
||
order = 0
|
||
while order < limit:
|
||
for it in items:
|
||
if order >= limit:
|
||
return
|
||
# Newest-first ordering: once we pass the cutoff, the rest are older.
|
||
if cutoff is not None:
|
||
item_date = _published_date(it.get("publishedTimeText"))
|
||
if item_date is not None and item_date < cutoff:
|
||
return
|
||
it.setdefault("channelUsername", handle)
|
||
for key, value in channel_meta.items():
|
||
it.setdefault(key, value)
|
||
yield await _finalize(
|
||
it,
|
||
input_model=input_model,
|
||
source_input=source_input,
|
||
from_url=from_url,
|
||
order=order,
|
||
content_type=content_type,
|
||
)
|
||
order += 1
|
||
if not token:
|
||
return
|
||
data = await _post(
|
||
INNERTUBE_BROWSE_URL, build_innertube_payload(continuation_token=token)
|
||
)
|
||
if not data:
|
||
return
|
||
items, token = parse_fn(data)
|
||
if not items:
|
||
return
|
||
|
||
|
||
async def _channel_flow(
|
||
handle: str,
|
||
*,
|
||
input_model: YouTubeScrapeInput,
|
||
source_input: str,
|
||
) -> AsyncIterator[dict[str, Any]]:
|
||
"""Scrape a channel's videos, shorts, and streams — each capped independently.
|
||
|
||
The videos seed is fetched once and reused to derive channel-wide metadata
|
||
(identity, banner, and the About panel's deep fields) stamped on every item.
|
||
"""
|
||
videos_seed = await fetch_html(_channel_tab_url(handle, "videos"))
|
||
initial = extract_yt_initial_data(videos_seed) if videos_seed else None
|
||
channel_meta: dict[str, Any] = {}
|
||
if initial:
|
||
channel_meta = parse_channel_metadata(initial)
|
||
channel_meta.update(await _fetch_channel_about(initial))
|
||
|
||
cutoff = _published_date(input_model.oldestPostDate)
|
||
|
||
for tab, limit in (
|
||
("videos", input_model.maxResults),
|
||
("shorts", input_model.maxResultsShorts),
|
||
("streams", input_model.maxResultStreams),
|
||
):
|
||
async for item in _channel_tab_flow(
|
||
handle,
|
||
tab,
|
||
limit=limit,
|
||
input_model=input_model,
|
||
source_input=source_input,
|
||
channel_meta=channel_meta,
|
||
initial=initial if tab == "videos" else None,
|
||
cutoff=cutoff,
|
||
):
|
||
yield item
|
||
|
||
|
||
async def _playlist_flow(
|
||
playlist_id: str,
|
||
*,
|
||
input_model: YouTubeScrapeInput,
|
||
source_input: str,
|
||
) -> AsyncIterator[dict[str, Any]]:
|
||
limit = input_model.maxResults
|
||
if limit <= 0:
|
||
return
|
||
data = await _post(
|
||
INNERTUBE_BROWSE_URL, build_innertube_payload(browse_id=f"VL{playlist_id}")
|
||
)
|
||
# Phase 1: page the playlist for video ids (cheap browse calls, sequential
|
||
# because each continuation depends on the last).
|
||
seen: set[str] = set()
|
||
ordered_ids: list[str] = []
|
||
while data and len(ordered_ids) < limit:
|
||
ids, token = parse_playlist_video_ids(data)
|
||
# A short playlist emits a spurious continuation whose page is empty;
|
||
# stopping on "no new ids" ends both real exhaustion and that loop.
|
||
new_ids = [v for v in ids if v not in seen]
|
||
if not new_ids:
|
||
break
|
||
for vid in new_ids:
|
||
seen.add(vid)
|
||
ordered_ids.append(vid)
|
||
if len(ordered_ids) >= limit:
|
||
break
|
||
if not token:
|
||
break
|
||
data = await _post(
|
||
INNERTUBE_BROWSE_URL, build_innertube_payload(continuation_token=token)
|
||
)
|
||
|
||
# Phase 2: resolve the videos concurrently — the per-video watch-page fetch
|
||
# is the bottleneck, so fan them out (each carries its playlist position in
|
||
# ``order``; fan_out emits as they finish, not in playlist order).
|
||
# ponytail: nested fan_out — when many playlist URLs run at once this can
|
||
# stack pools (outer × inner) of proxy sessions. Fine for the common
|
||
# single/few-playlist case; cap inner concurrency if bulk-playlist runs trip it.
|
||
jobs = [
|
||
_video_flow(
|
||
vid,
|
||
input_model=input_model,
|
||
source_input=source_input,
|
||
from_url=source_input,
|
||
order=i,
|
||
content_type="video",
|
||
)
|
||
for i, vid in enumerate(ordered_ids)
|
||
]
|
||
async for item in fan_out(jobs):
|
||
yield item
|
||
|
||
|
||
async def _hashtag_flow(
|
||
tag: str,
|
||
*,
|
||
input_model: YouTubeScrapeInput,
|
||
source_input: str,
|
||
) -> AsyncIterator[dict[str, Any]]:
|
||
"""Scrape the dedicated hashtag feed (not a #tag search).
|
||
|
||
The hashtag page embeds its feed as ``videoRenderer`` lockups (reused via
|
||
``parse_search_response``). ponytail: YouTube exposes no continuation for the
|
||
hashtag feed through this path, so it is a single page (~20-35 videos); the
|
||
paging loop is kept for the day a token appears. Upgrade path for more depth:
|
||
fall back to the ``#tag`` search route.
|
||
"""
|
||
limit = input_model.maxResults
|
||
if limit <= 0:
|
||
return
|
||
url = f"https://www.youtube.com/hashtag/{quote(tag)}"
|
||
html = await fetch_html(url)
|
||
if not html:
|
||
return
|
||
data = extract_yt_initial_data(html)
|
||
order = 0
|
||
while data:
|
||
items, token = parse_search_response(data)
|
||
for it in items:
|
||
if order >= limit:
|
||
return
|
||
yield await _finalize(
|
||
it,
|
||
input_model=input_model,
|
||
source_input=source_input,
|
||
from_url=url,
|
||
order=order,
|
||
content_type="video",
|
||
)
|
||
order += 1
|
||
if not token or order >= limit:
|
||
return
|
||
data = await _post(
|
||
INNERTUBE_BROWSE_URL, build_innertube_payload(continuation_token=token)
|
||
)
|
||
|
||
|
||
async def _dispatch(
|
||
resolved: ResolvedUrl, input_model: YouTubeScrapeInput
|
||
) -> AsyncIterator[dict[str, Any]]:
|
||
if resolved.kind == "video":
|
||
content_type = "shorts" if "/shorts/" in resolved.url else "video"
|
||
async for item in _video_flow(
|
||
resolved.value,
|
||
input_model=input_model,
|
||
source_input=resolved.url,
|
||
from_url=resolved.url,
|
||
order=0,
|
||
content_type=content_type,
|
||
):
|
||
yield item
|
||
elif resolved.kind == "channel":
|
||
async for item in _channel_flow(
|
||
resolved.value, input_model=input_model, source_input=resolved.url
|
||
):
|
||
yield item
|
||
elif resolved.kind == "playlist":
|
||
async for item in _playlist_flow(
|
||
resolved.value, input_model=input_model, source_input=resolved.url
|
||
):
|
||
yield item
|
||
elif resolved.kind == "hashtag":
|
||
async for item in _hashtag_flow(
|
||
resolved.value, input_model=input_model, source_input=resolved.url
|
||
):
|
||
yield item
|
||
elif resolved.kind == "search":
|
||
async for item in _search_flow(
|
||
resolved.value, input_model=input_model, source_input=resolved.url
|
||
):
|
||
yield item
|
||
|
||
|
||
async def iter_youtube(
|
||
input_model: YouTubeScrapeInput,
|
||
) -> AsyncIterator[dict[str, Any]]:
|
||
"""Yield Apify-shaped video items. startUrls override searchQueries.
|
||
|
||
Independent startUrls / queries fan out concurrently; each flow's own
|
||
continuation paging stays sequential.
|
||
"""
|
||
if input_model.startUrls:
|
||
jobs = []
|
||
for entry in input_model.startUrls:
|
||
resolved = resolve_url(entry.url)
|
||
if not resolved:
|
||
logger.warning("Unrecognized YouTube URL: %s", entry.url)
|
||
continue
|
||
jobs.append(_dispatch(resolved, input_model))
|
||
async for item in fan_out(jobs):
|
||
yield item
|
||
return
|
||
|
||
jobs = [
|
||
_search_flow(query, input_model=input_model, source_input=query)
|
||
for query in input_model.searchQueries
|
||
]
|
||
async for item in fan_out(jobs):
|
||
yield item
|
||
|
||
|
||
async def scrape_youtube(
|
||
input_model: YouTubeScrapeInput, *, limit: int | None = None
|
||
) -> list[dict[str, Any]]:
|
||
"""Collect :func:`iter_youtube` into a list, honoring an optional ``limit``.
|
||
|
||
``limit`` is a request-time policy guard (used by the route), NOT a ceiling
|
||
in the streaming core.
|
||
"""
|
||
results: list[dict[str, Any]] = []
|
||
async for item in iter_youtube(input_model):
|
||
results.append(item)
|
||
if limit is not None and len(results) >= limit:
|
||
break
|
||
return results
|