feat(native-connector): added youtube scrapers

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-07-02 01:10:31 -07:00
parent f964e59686
commit 99378d7b63
19 changed files with 3959 additions and 0 deletions

View file

@ -0,0 +1,36 @@
---
description: Ponytail, lazy senior dev mode. Always pick the simplest solution that works.
globs:
alwaysApply: true
---
# Ponytail, lazy senior dev mode
You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.
Before writing any code, stop at the first rung that holds:
1. Does this need to be built at all? (YAGNI)
2. Does it already exist in this codebase? Reuse the helper, util, or pattern that's already here, don't re-write it.
3. Does the standard library already do this? Use it.
4. Does a native platform feature cover it? Use it.
5. Does an already-installed dependency solve it? Use it.
6. Can this be one line? Make it one line.
7. Only then: write the minimum code that works.
The ladder runs after you understand the problem, not instead of it: read the task and the code it touches, trace the real flow end to end, then climb.
Bug fix = root cause, not symptom: a report names a symptom. Grep every caller of the function you touch and fix the shared function once — one guard there is a smaller diff than one per caller, and patching only the path the ticket names leaves a sibling caller still broken.
Rules:
- No abstractions that weren't explicitly requested.
- No new dependency if it can be avoided.
- No boilerplate nobody asked for.
- Deletion over addition. Boring over clever. Fewest files possible.
- Shortest working diff wins, but only once you understand the problem. The smallest change in the wrong place isn't lazy, it's a second bug.
- Question complex requests: "Do you actually need X, or does Y cover it?"
- Pick the edge-case-correct option when two stdlib approaches are the same size, lazy means less code, not the flimsier algorithm.
- Mark intentional simplifications with a `ponytail:` comment. If the shortcut has a known ceiling (global lock, O(n²) scan, naive heuristic), the comment names the ceiling and the upgrade path.
Not lazy about: understanding the problem (read it fully and trace the real flow before picking a rung, a small diff you don't understand is just laziness dressed up as efficiency), input validation at trust boundaries, error handling that prevents data loss, security, accessibility, the calibration real hardware needs (the platform is never the spec ideal, a clock drifts, a sensor reads off), anything explicitly requested. Lazy code without its check is unfinished: non-trivial logic leaves ONE runnable check behind, the smallest thing that fails if the logic breaks (an assert-based demo/self-check or one small test file; no frameworks, no fixtures). Trivial one-liners need no test.

36
.rules/ponytail.mdc Normal file
View file

@ -0,0 +1,36 @@
---
description: Ponytail, lazy senior dev mode. Always pick the simplest solution that works.
globs:
alwaysApply: true
---
# Ponytail, lazy senior dev mode
You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.
Before writing any code, stop at the first rung that holds:
1. Does this need to be built at all? (YAGNI)
2. Does it already exist in this codebase? Reuse the helper, util, or pattern that's already here, don't re-write it.
3. Does the standard library already do this? Use it.
4. Does a native platform feature cover it? Use it.
5. Does an already-installed dependency solve it? Use it.
6. Can this be one line? Make it one line.
7. Only then: write the minimum code that works.
The ladder runs after you understand the problem, not instead of it: read the task and the code it touches, trace the real flow end to end, then climb.
Bug fix = root cause, not symptom: a report names a symptom. Grep every caller of the function you touch and fix the shared function once — one guard there is a smaller diff than one per caller, and patching only the path the ticket names leaves a sibling caller still broken.
Rules:
- No abstractions that weren't explicitly requested.
- No new dependency if it can be avoided.
- No boilerplate nobody asked for.
- Deletion over addition. Boring over clever. Fewest files possible.
- Shortest working diff wins, but only once you understand the problem. The smallest change in the wrong place isn't lazy, it's a second bug.
- Question complex requests: "Do you actually need X, or does Y cover it?"
- Pick the edge-case-correct option when two stdlib approaches are the same size, lazy means less code, not the flimsier algorithm.
- Mark intentional simplifications with a `ponytail:` comment. If the shortcut has a known ceiling (global lock, O(n²) scan, naive heuristic), the comment names the ceiling and the upgrade path.
Not lazy about: understanding the problem (read it fully and trace the real flow before picking a rung, a small diff you don't understand is just laziness dressed up as efficiency), input validation at trust boundaries, error handling that prevents data loss, security, accessibility, the calibration real hardware needs (the platform is never the spec ideal, a clock drifts, a sensor reads off), anything explicitly requested. Lazy code without its check is unfinished: non-trivial logic leaves ONE runnable check behind, the smallest thing that fails if the logic breaks (an assert-based demo/self-check or one small test file; no frameworks, no fixtures). Trivial one-liners need no test.

View file

@ -9,6 +9,12 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from scrapling.fetchers import AsyncFetcher
from app.auth.context import AuthContext
from app.scrapers.youtube import (
YouTubeCommentsInput,
YouTubeScrapeInput,
scrape_comments,
scrape_youtube,
)
from app.users import require_session_context
from app.utils.proxy import get_proxy_url
@ -26,6 +32,45 @@ _INNERTUBE_CLIENT = {
}
@router.post("/youtube/scrape")
async def scrape_youtube_route(
payload: YouTubeScrapeInput,
_auth: AuthContext = Depends(require_session_context),
) -> list[dict]:
"""Scrape public YouTube data (search / video / channel / playlist / shorts).
Apify YouTube Scraper-compatible input/output. The scrape runs inline and is
bounded only by the request's own ``maxResults`` / ``maxResultsShorts`` /
``maxResultStreams`` no separate per-request ceiling.
"""
try:
return await scrape_youtube(payload)
except Exception as e:
logger.error("YouTube scrape failed: %s", e)
raise HTTPException(
status_code=502, detail=f"YouTube scrape failed: {e!s}"
) from e
@router.post("/youtube/comments")
async def scrape_comments_route(
payload: YouTubeCommentsInput,
_auth: AuthContext = Depends(require_session_context),
) -> list[dict]:
"""Scrape YouTube comments (+ replies) for the given video URLs.
Apify "YouTube Comments Scraper"-compatible. Runs inline and is bounded only
by the request's own ``maxComments`` — no separate per-request ceiling.
"""
try:
return await scrape_comments(payload)
except Exception as e:
logger.error("YouTube comments scrape failed: %s", e)
raise HTTPException(
status_code=502, detail=f"YouTube comments scrape failed: {e!s}"
) from e
@router.get("/youtube/playlist-videos")
async def get_playlist_videos(
url: str = Query(..., description="YouTube playlist URL"),

View file

@ -0,0 +1 @@
"""Platform-native scrapers (non-connector data extraction tools)."""

View file

@ -0,0 +1,287 @@
# YouTube Scraper
A platform-native YouTube scraper that is a **drop-in clone of the Apify
"YouTube Scraper" and "YouTube Comments Scraper" actors** — same input surface,
same output item shape. It talks to YouTube's internal **InnerTube** API plus
the public watch/channel HTML, egresses through a residential proxy, and streams
Apify-shaped dicts.
No API keys, no Apify account, no headless browser on the happy path.
---
## Quick start
```python
from app.scrapers.youtube import (
YouTubeScrapeInput, scrape_youtube,
YouTubeCommentsInput, scrape_comments,
)
# Videos — by search query and/or direct URLs (video/channel/playlist/hashtag/search)
videos = await scrape_youtube(
YouTubeScrapeInput(searchQueries=["surfsense"], maxResults=50)
)
videos = await scrape_youtube(
YouTubeScrapeInput(startUrls=[{"url": "https://www.youtube.com/@SomeChannel"}],
maxResults=20, downloadSubtitles=True)
)
# Comments — one output item per top-level comment AND per reply
comments = await scrape_comments(
YouTubeCommentsInput(
startUrls=[{"url": "https://www.youtube.com/watch?v=VIDEO_ID"}],
maxComments=200, sortCommentsBy="TOP_COMMENTS",
)
)
```
Both have a streaming twin — `iter_youtube()` / `iter_comments()` — that yields
items as they arrive (unbounded, continuation-paged). `scrape_*` is just a
collector with an optional `limit` guard.
The HTTP surface lives in `app/routes/youtube_routes.py`.
---
## Module map
| File | Responsibility |
|------|----------------|
| `__init__.py` | Public exports (entry points + schemas). |
| `schemas.py` | Pydantic input/output models mirroring the Apify camelCase spec. `extra="allow"` on outputs keeps the contract open. |
| `scraper.py` | Video orchestrator. Resolves URLs → per-flow async generators (`_video_flow`, `_search_flow`, `_channel_flow`, `_playlist_flow`), runs them through the `fan_out` worker pool. |
| `comments.py` | Comments orchestrator. Watch page → comments-section continuation → `/next` paging, with concurrent per-thread reply fetching. |
| `innertube.py` | **The network seam.** Proxy-only fetch (`fetch_html`, `post_innertube`), reusable sticky-IP sessions, reactive IP rotation, `StealthyFetcher` fallback, and the InnerTube payload builder. |
| `parsers.py` | Pure, I/O-free JSON/HTML traversal + normalization (`find_all`/`find_first`/`dig`, `parse_video_page`, `parse_search_response`, comment/continuation token extractors, `parse_count`, …). |
| `url_resolver.py` | Classify a URL into `video` / `channel` / `playlist` / `hashtag` / `search` and extract its id. |
| `search_filters.py` | Encode Apify search filters into YouTube's `sp=` base64 protobuf (sort/date/type/length/feature flags), composable. |
| `subtitles.py` | Subtitle download via `youtube-transcript-api`, shaped to Apify `subtitles[]`. |
Everything in `parsers.py` is deterministic and unit-tested offline; everything
that touches the network is funneled through `innertube.py`.
---
## How it fetches (the important part)
All network I/O goes through **`fetch_html`** (GET watch/channel pages) and
**`post_innertube`** (POST InnerTube `browse`/`search`/`next`). Design rules:
1. **Proxy-only egress.** Every request goes through the residential proxy
(`app/utils/proxy.get_proxy_url`). We never connect directly — a direct hit
would expose and risk-block the server IP.
2. **Session reuse = sticky IP.** Within one flow (a continuation chain, or the
jobs a worker pulls), a single keep-alive `FetcherSession` is reused. This
roughly **halves warm latency** (~2.1s → ~1.0s) because only the first
request pays the TCP+TLS handshake, and it pins one sticky exit IP instead of
drawing a new (often slow) residential node per request.
3. **Reactive IP rotation.** A sticky IP is kept until it's actually blocked. On
`403`/`429` or a connection error, the session rotates to a fresh IP and
retries, up to `_MAX_ROTATIONS` (3). A probe of 120 sequential requests on
one IP saw zero blocks, so rotation is reactive, not proactive.
4. **Browser fallback.** If all proxy attempts fail on an HTML page, `fetch_html`
falls back to `StealthyFetcher` (headless, `solve_cloudflare=True`) in a
worker thread. Optional — needs patchright browsers installed. Age-gated
content requires login and is **not** bypassable.
The active session is bound to the current async task via a `ContextVar`
(`_current_session`), so parsers and orchestrators never thread a session
argument through every call — each concurrent flow transparently uses its own
session/IP.
### InnerTube payloads
`build_innertube_payload(...)` builds the `WEB` client `context` payload
(I/O-free, unit-testable). Some endpoints reject a keyless POST; `scraper._post`
retries once with the public web key (`INNERTUBE_PUBLIC_API_KEY`) when the
keyless call returns nothing. `hl=<lang>` on a `/next` call returns the
creator-localized title/description (the translation flow).
---
## Concurrency model
Independent jobs — each `startUrl`, each `searchQuery`, each comment video — run
concurrently through **`fan_out`**, a warm **worker pool** (`_FANOUT_CONCURRENCY
= 16`):
- Each worker opens **one** proxy session and reuses it across the sequential
jobs it pulls, so only the first job per worker pays the handshake.
- **A bad job yields nothing rather than aborting the batch** (per-job
try/except). One dead URL / comments-disabled video never kills the run.
- Results stream out as each job finishes; **within** a flow, continuation
paging stays sequential.
- If the consumer stops early (collector hits its `limit`), workers are
cancelled and **awaited** so every session's `finally` closes — no leaked
keep-alive connections.
Comment reply threads for a page are fetched **concurrently** on the same
multiplexed session (`asyncio.gather`), capped at the remaining budget.
---
## Data flow
- **Video by URL** → fetch watch HTML → `parse_video_page` (reads
`ytInitialData` + `ytInitialPlayerResponse`) → optional subtitles + translation.
- **Search** → InnerTube `/search` (+ `sp=` filter protobuf) → paginate via
continuation tokens up to `maxResults`.
- **Channel** → fetch the videos-tab seed once (reused for channel-wide metadata
+ the About panel via `/browse`), then page `videos` / `shorts` / `streams`
tabs, each capped independently (`maxResults` / `maxResultsShorts` /
`maxResultStreams`). `sortVideosBy` uses the sort chips; `oldestPostDate` cuts
off newest-first.
- **Playlist**`/browse` `VL<id>` → resolve each video via the video flow.
- **Comments** → watch HTML seeds the comments-section token → `/next` returns
comment entities + per-thread reply tokens + the page token. `maxComments`
counts **every** emitted item (comments + replies).
### `commentsCount`
For the **comments** scraper, the authoritative total is read from the
comments-section header (`commentsHeaderRenderer.countText`), not the watch-page
HTML where it's lazy-loaded/absent. **Known gap:** the **video** scraper's
`VideoItem.commentsCount` still comes from search/watch HTML and is often `null`
— it would need an extra `/next` call to backfill (intentionally not done to
keep the video path cheap).
---
## API spec
Mirrors the Apify "YouTube Scraper" and "YouTube Comments Scraper" actors
(camelCase, `extra="allow"`). Inputs use Pydantic defaults; **every field is
additive** — unknown inputs are accepted, unsourced outputs come back as
`None`/`[]` — so parity grows without breaking consumers. `schemas.py` is the
source of truth.
### Video scraper — input (`YouTubeScrapeInput`)
| Field | Type / values | Default | Notes |
|-------|---------------|---------|-------|
| `searchQueries` | `string[]` | `[]` | Discovery by query. Ignored when `startUrls` is set. |
| `startUrls` | `[{ "url": string }]` | `[]` | Direct URLs: video, channel, playlist, hashtag, search. Overrides `searchQueries`. |
| `maxResults` | `int ≥ 0` | `0` | Cap of regular videos **per query and per channel**. `0` = fetch none. |
| `maxResultsShorts` | `int ≥ 0` | `0` | Cap of Shorts per channel. |
| `maxResultStreams` | `int ≥ 0` | `0` | Cap of live/streams per channel. |
| `downloadSubtitles` | `bool` | `false` | Populate `subtitles[]`. |
| `subtitlesLanguage` | `string` | `"en"` | Also drives the translation flow when non-`en` (see `translatedTitle`). |
| `subtitlesFormat` | `srt` \| `vtt` \| `xml` \| `plaintext` | `"srt"` | |
| `preferAutoGeneratedSubtitles` | `bool` | `false` | |
| `saveSubsToKVS` | `bool` | `false` | Accepted for parity; no-op (Apify key-value-store concept). |
| `sortingOrder` | `relevance` \| `rating` \| `date` \| `views` | `null` | Search only. |
| `dateFilter` | `hour` \| `today` \| `week` \| `month` \| `year` | `null` | Search only. |
| `videoType` | `video` \| `movie` | `null` | Search only. |
| `lengthFilter` | `under4` \| `between420` \| `plus20` | `null` | Search only (<4min / 420min / >20min). |
| `isHD` `hasSubtitles` `hasCC` `is3D` `isLive` `isBought` `is4K` `is360` `hasLocation` `isHDR` `isVR180` | `bool` | `null` | Search feature filters (encoded into `sp=`). |
| `oldestPostDate` | `string` (date) | `null` | Channel cutoff; day-accurate (relative times). |
| `sortVideosBy` | `NEWEST` \| `POPULAR` \| `OLDEST` | `null` | Channel videos tab sort chip. |
### Video scraper — output (`VideoItem`)
| Field | Type | Populated? |
|-------|------|-----------|
| `title` `id` `url` `viewCount` `date` `duration` | str/int | yes |
| `type` | `video` \| `shorts` \| `stream` | yes |
| `thumbnailUrl` | str | yes |
| `input` `fromYTUrl` `order` | str/int | yes (provenance: source query/URL, origin URL, index) |
| `text` | str | yes (description) |
| `descriptionLinks` | `[{ url, text }]` | yes |
| `hashtags` | `string[]` | yes |
| `likes` `commentsCount` `commentsTurnedOff` | int/bool | partial (often `null` on the video path — see `commentsCount` note) |
| `location` | str | when present |
| `collaborators` | `[{ name, username, url }]` | when present |
| `translatedTitle` `translatedText` | str | when `subtitlesLanguage != "en"` |
| `subtitles` | `[{ srtUrl, type, language, srt }]` | when `downloadSubtitles` |
| `isMembersOnly` `isPaidContent` | bool | yes (default `false`) |
| `isMonetized` `isAgeRestricted` | bool | best-effort (`null` when unknown) |
| `channelName` `channelUrl` `channelUsername` `channelId` | str | yes |
| `numberOfSubscribers` `channelTotalVideos` `channelTotalViews` | int | channel/deep fields |
| `channelDescription` `channelLocation` `channelJoinedDate` | str | channel About panel |
| `isChannelVerified` `channelBannerUrl` `channelAvatarUrl` | bool/str | channel fields |
### Comments scraper — input (`YouTubeCommentsInput`)
| Field | Type / values | Default | Notes |
|-------|---------------|---------|-------|
| `startUrls` | `[{ "url": string }]` | `[]` | Video URLs only (non-video URLs skipped). |
| `maxComments` | `int ≥ 1` | `1` | Counts **every** emitted item (top-level comments **and** replies). |
| `sortCommentsBy` | `TOP_COMMENTS` \| `NEWEST_FIRST` | `"NEWEST_FIRST"` | |
| `oldestCommentDate` | `string` (date) | `null` | Forces newest-first and stops at the cutoff. |
### Comments scraper — output (`CommentItem`)
| Field | Type | Notes |
|-------|------|-------|
| `cid` | str | Comment id. |
| `comment` | str | Text. |
| `author` | str | |
| `type` | `comment` \| `reply` | |
| `replyToCid` | str | Parent `cid` (replies only). |
| `replyCount` | int | Replies under a top-level comment. |
| `voteCount` | int | Likes. |
| `authorIsChannelOwner` `hasCreatorHeart` | bool | |
| `publishedTimeText` | str | Relative time ("2 days ago"). |
| `videoId` `pageUrl` `title` | str | Source video. |
| `commentsCount` | int | Authoritative total from the comments header. |
---
## Configuration
- **Proxy** — required for real runs; configured via `app/utils/proxy.py`
(residential rotating gateway env vars). With no proxy configured the fetchers
fall back to one-shot direct `AsyncFetcher` calls (fine for local tests, not
for production).
- **Concurrency**`scraper._FANOUT_CONCURRENCY` (16). The gateway handled 64
parallel flows with zero failures in a ramp probe, so this leaves headroom.
- **Rotation**`innertube._BLOCK_STATUSES` (`403`, `429`) and
`_MAX_ROTATIONS` (3).
---
## Testing
- **Offline unit tests** (no network) — run these on every change:
```bash
cd surfsense_backend
.venv/Scripts/python.exe -m pytest tests/unit/scrapers/youtube/
```
- `test_parsers.py` — parser/normalization + filter-protobuf + URL-resolver
cases against hand-built and (if present) captured real fixtures.
- `test_fetch_resilience.py` — deterministic rotate-on-block (`429`/error →
rotate → `200`, exhaustion, no-rotate on `404`, stealthy fallback) and the
`fan_out` no-session-leak-on-early-stop guarantee, all with stubbed sessions.
- **Live functional harness**`scripts/e2e_youtube_scraper.py` (needs live
network + optional proxy creds). Exercises video/search/channel/comments/
location/collaborators/translation end to end, and **regenerates the offline
fixtures** into `tests/unit/scrapers/youtube/fixtures/`:
```bash
.venv/Scripts/python.exe scripts/e2e_youtube_scraper.py
```
---
## Extending it
- **Add an output field** → populate it in the relevant `parsers.py` function
and add it to `schemas.py`. Because outputs are `extra="allow"`, forgetting the
schema line won't drop the value, but declaring it documents the contract.
- **Add a URL kind** → extend `url_resolver.resolve_url` + add a `_*_flow` in
`scraper.py` and a branch in `_dispatch`.
- **Add a search filter** → add the field to `YouTubeScrapeInput` and encode it
in `search_filters.build_search_params` (verify byte-for-byte against a real
YouTube `sp=` token in the unit test).
### Known ceilings (grep `ponytail:` in the source for the live list)
- Hashtag pages are routed through search (`#tag` query), not a dedicated
hashtag browse.
- `oldestPostDate` / `oldestCommentDate` cutoffs are day-accurate at best
(channel/list pages only expose coarse relative times like "2 years ago").
- Keyless-vs-keyed InnerTube retry does one extra request on the keyed path
instead of remembering which worked.
- Video-path `commentsCount` (see above).
- Playlist scraping reads only the first `/browse` page (~100 videos); paging
the continuation token for longer playlists is not yet wired.

View file

@ -0,0 +1,16 @@
"""Platform-native YouTube scraper (Apify YouTube Scraper-compatible)."""
from .comments import iter_comments, scrape_comments
from .schemas import CommentItem, VideoItem, YouTubeCommentsInput, YouTubeScrapeInput
from .scraper import iter_youtube, scrape_youtube
__all__ = [
"CommentItem",
"VideoItem",
"YouTubeCommentsInput",
"YouTubeScrapeInput",
"iter_comments",
"iter_youtube",
"scrape_comments",
"scrape_youtube",
]

View file

@ -0,0 +1,184 @@
"""Orchestrator for the YouTube Comments scraper (Apify-compatible).
Distinct from the video scraper: one output item per top-level comment *and*
per reply, sourced from the InnerTube ``/next`` endpoint. The watch page seeds a
comments-section continuation; ``/next`` then returns comment entity payloads,
per-thread reply tokens, and the paging token. ``maxComments`` counts every
emitted item (comments + replies), matching Apify.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import AsyncIterator
from typing import Any
from .innertube import INNERTUBE_NEXT_URL, build_innertube_payload, fetch_html
from .parsers import (
comment_next_token,
comment_reply_tokens,
comment_section_token,
comment_sort_tokens,
dig,
extract_yt_initial_data,
find_first,
parse_comment_entities,
parse_count,
parse_video_page,
)
from .scraper import _post, _published_date, fan_out
from .schemas import CommentItem, YouTubeCommentsInput
from .url_resolver import resolve_url
logger = logging.getLogger(__name__)
# Apify sort value -> YouTube sort-menu label. The section token loads "Top" by
# default, so only "Newest" needs an explicit switch request.
_SORT_LABELS = {"TOP_COMMENTS": "Top", "NEWEST_FIRST": "Newest"}
async def _post_next(token: str) -> dict[str, Any] | None:
return await _post(INNERTUBE_NEXT_URL, build_innertube_payload(continuation_token=token))
def _finalize(partial: dict[str, Any], base: dict[str, Any], reply_to: str | None) -> dict:
return CommentItem(**{**base, **partial, "replyToCid": reply_to}).to_output()
def _older(entity: dict[str, Any], cutoff) -> bool:
when = _published_date(entity.get("publishedTimeText"))
return when is not None and when < cutoff
async def _collect_replies(
token: str, *, parent_cid: str, base: dict[str, Any], cutoff
) -> list[dict]:
"""Collect one thread's replies (``replyLevel`` 1) until exhausted."""
replies: list[dict] = []
while token:
data = await _post_next(token)
if not data:
break
for entity in parse_comment_entities(data):
if cutoff and _older(entity, cutoff):
continue
replies.append(_finalize(entity, base, parent_cid))
token = comment_next_token(data)
return replies
async def _comments_for_video(
video_id: str, input_model: YouTubeCommentsInput
) -> AsyncIterator[dict]:
limit = input_model.maxComments
page_url = f"https://www.youtube.com/watch?v={video_id}"
html = await fetch_html(page_url)
if not html:
return
initial = extract_yt_initial_data(html)
if not initial:
return
section = comment_section_token(initial)
if not section: # comments disabled or absent
return
meta = parse_video_page(html) or {}
base = {
"videoId": video_id,
"pageUrl": page_url,
"title": meta.get("title"),
"commentsCount": meta.get("commentsCount"),
}
data = await _post_next(section)
if not data:
return
# Authoritative total lives in the comments-section header (exact integer),
# not the watch-page HTML where the count is lazy-loaded / absent.
header = find_first(data, "commentsHeaderRenderer")
if header:
base["commentsCount"] = parse_count(dig(header, "countText", "runs", 0, "text"))
# oldestCommentDate forces newest-first (Apify behavior).
cutoff = _published_date(input_model.oldestCommentDate)
sort = "NEWEST_FIRST" if cutoff else input_model.sortCommentsBy
label = _SORT_LABELS[sort]
if label == "Newest":
sort_tokens = comment_sort_tokens(data)
if label in sort_tokens:
switched = await _post_next(sort_tokens[label])
if switched:
data = switched
count = 0
while data:
reply_tokens = comment_reply_tokens(data)
entities = parse_comment_entities(data)
# Fetch this page's reply threads concurrently (the reused session
# multiplexes requests), then emit in Apify order: comment, its replies.
# ponytail: prefetch is capped at the remaining budget — each thread
# yields >=1 item, so more threads than budget can't all be needed.
pending = [
(e["cid"], reply_tokens[e["cid"]])
for e in entities
if e["cid"] in reply_tokens
][: max(limit - count, 0)]
fetched = await asyncio.gather(
*(
_collect_replies(tok, parent_cid=cid, base=base, cutoff=cutoff)
for cid, tok in pending
)
)
replies_by_cid = {cid: r for (cid, _), r in zip(pending, fetched)}
for entity in entities:
if count >= limit:
return
# Newest-first: the first too-old comment means the rest are older.
if cutoff and _older(entity, cutoff):
return
yield _finalize(entity, base, None)
count += 1
for reply in replies_by_cid.get(entity["cid"], []):
if count >= limit:
return
yield reply
count += 1
token = comment_next_token(data)
if not token or count >= limit:
return
data = await _post_next(token)
async def iter_comments(input_model: YouTubeCommentsInput) -> AsyncIterator[dict]:
"""Yield Apify-shaped comment items for every startUrl video.
Videos are scraped concurrently (each video's own comment paging stays
sequential), which is the dominant speedup when reviewing many videos.
"""
jobs = []
for entry in input_model.startUrls:
resolved = resolve_url(entry.url)
if not resolved or resolved.kind != "video":
logger.warning("Comments: not a video URL, skipping: %s", entry.url)
continue
jobs.append(_comments_for_video(resolved.value, input_model))
async for comment in fan_out(jobs):
yield comment
async def scrape_comments(
input_model: YouTubeCommentsInput, *, limit: int | None = None
) -> list[dict]:
"""Collect :func:`iter_comments` into a list, honoring an optional guard."""
results: list[dict] = []
async for comment in iter_comments(input_model):
results.append(comment)
if limit is not None and len(results) >= limit:
break
return results

View file

@ -0,0 +1,315 @@
"""Proxy-aware fetch seam + InnerTube payload builder for the YouTube scraper.
All network I/O for the scraper flows through :func:`fetch_html` and
:func:`post_innertube`, and always egresses through the residential proxy (never
a direct connection that would expose and risk-block the server IP). Within a
continuation chain, :func:`proxy_session` opens ONE reused keep-alive session so
sequential pages share a connection and a sticky exit IP (roughly halving warm
latency vs a fresh rotating IP per request). HTML pages fall back to a
``StealthyFetcher`` browser tier for anti-bot walls. Parsers and the orchestrator
never see which tier served the bytes.
The ``AsyncFetcher.post(..., json=...)`` + ``page.json()`` / ``page.html_content``
pattern is already proven in ``app/routes/youtube_routes.py``.
"""
from __future__ import annotations
import asyncio
import logging
import time
from contextlib import asynccontextmanager
from contextvars import ContextVar
from typing import Any
from scrapling.fetchers import AsyncFetcher, FetcherSession
from app.utils.proxy import get_proxy_url
try: # browser tier is optional (needs patchright browsers installed)
from scrapling.fetchers import StealthyFetcher
except Exception: # pragma: no cover - import guard
StealthyFetcher = None # type: ignore[assignment]
logger = logging.getLogger(__name__)
# Per-flow proxy session (set by ``proxy_session`` around one continuation
# chain). Reusing one keep-alive connection halves warm latency AND pins a
# single sticky exit IP on the rotating gateway, instead of paying a fresh
# TCP+TLS handshake and drawing a new (often slow) residential node per request.
# A ContextVar keeps each concurrent fan-out flow on its own session/IP without
# threading a param through every parser call.
_current_session: ContextVar[_RotatingSession | None] = ContextVar(
"yt_proxy_session", default=None
)
# Statuses that mean "this IP is throttled/blocked" -> rotate to a fresh one.
# A probe of 120 sequential requests on one sticky IP saw zero blocks, so we
# stay sticky for speed and rotate only *reactively*, up to this many times per
# request. ponytail upgrade path: also rotate proactively every N requests if a
# future run ever trips these.
_BLOCK_STATUSES = frozenset({403, 429})
_MAX_ROTATIONS = 3
class _RotatingSession:
"""Owns one live ``FetcherSession`` (sticky IP) and can swap it for a fresh one.
``rotate()`` closes the current keep-alive connection and opens a new one, so
the rotating gateway hands out a different residential exit IP. Used
sequentially within a single flow (never shared across concurrent tasks), so
no locking is needed. ``session`` is ``None`` only when no proxy is configured.
"""
def __init__(self) -> None:
self._cm: Any | None = None
self.session: Any | None = None
self.rotations = 0
async def _open(self) -> None:
proxy = get_proxy_url()
if proxy is None:
self._cm = self.session = None
return
self._cm = FetcherSession(
proxy=proxy, stealthy_headers=True, impersonate="chrome"
)
self.session = await self._cm.__aenter__()
async def close(self) -> None:
if self._cm is not None:
try:
await self._cm.__aexit__(None, None, None)
except Exception: # best-effort teardown
pass
self._cm = self.session = None
async def rotate(self) -> Any | None:
"""Drop the current IP and connect through a fresh one. Returns new session."""
await self.close()
self.rotations += 1
await self._open()
logger.info("[youtube] rotated proxy session (rotation #%d)", self.rotations)
return self.session
async def open_proxy_holder() -> _RotatingSession:
"""Open a warm rotate-on-block session holder (caller owns ``close()``)."""
holder = _RotatingSession()
await holder._open()
return holder
@asynccontextmanager
async def bind_proxy_holder(holder: _RotatingSession):
"""Route this task's fetches through ``holder`` for the enclosed block.
Does NOT close the holder enables pooling warm sessions across sequential
jobs so each job skips the ~2s proxy TCP+TLS handshake.
"""
token = _current_session.set(holder)
try:
yield holder
finally:
_current_session.reset(token)
@asynccontextmanager
async def proxy_session():
"""Open one reused, rotate-on-block proxy session for a continuation chain."""
holder = await open_proxy_holder()
try:
async with bind_proxy_holder(holder):
yield holder
finally:
await holder.close()
# Consent cookies to dodge the EU consent interstitial that otherwise returns a
# page with no ``ytInitialData``. Mirrors app/routes/youtube_routes.py.
CONSENT_COOKIES = {
"CONSENT": "PENDING+999",
"SOCS": "CAISNQgDEitib3FfaWRlbnRpdHlmcm9udGVuZHVpc2VydmVyXzIwMjMwODI5LjA3X3AxGgJlbiADGgYIgOa_pgY",
}
# Public InnerTube "WEB" client block. The web API key below is YouTube's
# long-standing public key; ``search``/``next`` may reject a keyless POST where
# ``browse`` accepts it, so the builder can attach both key and visitorData.
INNERTUBE_PUBLIC_API_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8"
INNERTUBE_BROWSE_URL = "https://www.youtube.com/youtubei/v1/browse"
INNERTUBE_SEARCH_URL = "https://www.youtube.com/youtubei/v1/search"
INNERTUBE_NEXT_URL = "https://www.youtube.com/youtubei/v1/next"
def build_innertube_payload(
*,
continuation_token: str | None = None,
browse_id: str | None = None,
search_query: str | None = None,
search_params: str | None = None,
visitor_data: str | None = None,
video_id: str | None = None,
hl: str | None = None,
) -> dict[str, Any]:
"""Build the standard InnerTube ``context.client`` payload.
Ported from the Scrapfly reference ``call_youtube_api`` but I/O-free so it is
unit-testable and reusable across browse/search/next/player endpoints.
``hl`` selects the response language: a ``/next`` call with a non-default
``hl`` returns the video's creator-localized title/description.
"""
client: dict[str, Any] = {
"hl": hl or "en",
"gl": "US",
"clientName": "WEB",
"clientVersion": "2.20241111.07.00",
"platform": "DESKTOP",
"userInterfaceTheme": "USER_INTERFACE_THEME_DARK",
}
if visitor_data:
client["visitorData"] = visitor_data
payload: dict[str, Any] = {
"context": {
"client": client,
"user": {"lockedSafetyMode": False},
"request": {"useSsl": True},
}
}
if browse_id is not None:
payload["browseId"] = browse_id
if video_id is not None:
payload["videoId"] = video_id
if search_query is not None:
payload["query"] = search_query
if search_params is not None:
payload["params"] = search_params
if continuation_token is not None:
payload["continuation"] = continuation_token
return payload
async def post_innertube(
base_url: str,
payload: dict[str, Any],
*,
api_key: str | None = None,
) -> dict[str, Any] | None:
"""POST an InnerTube payload and return parsed JSON (or ``None`` on failure).
Always egresses through the proxy (a reused per-flow session when one is
open, else a one-shot ``AsyncFetcher``). Never connects directly a direct
hit would expose and risk-block the server IP.
"""
url = f"{base_url}?key={api_key}" if api_key else base_url
holder = _current_session.get()
for attempt in range(_MAX_ROTATIONS + 1):
session = holder.session if holder else None
try:
started = time.perf_counter()
if session is not None:
page = await session.post(url, json=payload)
else:
page = await AsyncFetcher.post(
url, json=payload, proxy=get_proxy_url(), stealthy_headers=True
)
fetch_ms = (time.perf_counter() - started) * 1000
logger.info(
"[youtube][perf] source=innertube reused=%s url=%s status=%s fetch_ms=%.1f",
session is not None,
base_url,
page.status,
fetch_ms,
)
if page.status == 200:
return page.json()
logger.warning("InnerTube POST %s returned %s", base_url, page.status)
if not (holder and page.status in _BLOCK_STATUSES and attempt < _MAX_ROTATIONS):
return None
except Exception as e:
logger.warning("InnerTube POST %s failed: %s", base_url, e)
if not (holder and attempt < _MAX_ROTATIONS):
return None
await holder.rotate() # blocked/errored on a proxy session: try a fresh IP
return None
async def fetch_html(url: str, *, cookies: dict[str, str] | None = None) -> str | None:
"""GET a YouTube page and return raw HTML (or ``None`` on failure).
Fetches through the proxy (reused per-flow session when open, else a one-shot
``AsyncFetcher``); falls back to the ``StealthyFetcher`` browser tier for
anti-bot walls. Sets consent cookies by default so EU-egress fetches don't
hit the consent wall and return a page without ``ytInitialData``.
"""
merged = {**CONSENT_COOKIES, **(cookies or {})}
headers = {"Accept-Language": "en-US,en;q=0.9"}
holder = _current_session.get()
for attempt in range(_MAX_ROTATIONS + 1):
session = holder.session if holder else None
try:
started = time.perf_counter()
if session is not None:
page = await session.get(url, headers=headers, cookies=merged)
else:
page = await AsyncFetcher.get(
url,
headers=headers,
cookies=merged,
proxy=get_proxy_url(),
stealthy_headers=True,
)
fetch_ms = (time.perf_counter() - started) * 1000
logger.info(
"[youtube][perf] source=html reused=%s url=%s status=%s fetch_ms=%.1f",
session is not None,
url,
page.status,
fetch_ms,
)
if page.status == 200:
return page.html_content
logger.warning("HTML GET %s returned %s", url, page.status)
if not (holder and page.status in _BLOCK_STATUSES and attempt < _MAX_ROTATIONS):
break
except Exception as e:
logger.warning("HTML GET %s failed: %s", url, e)
if not (holder and attempt < _MAX_ROTATIONS):
break
await holder.rotate() # blocked/errored on a proxy session: try a fresh IP
# All proxy attempts blocked/failed -> last-resort browser tier.
return await _fetch_html_stealthy(url)
async def _fetch_html_stealthy(url: str) -> str | None:
"""Last-resort browser fetch for anti-bot walls (mirrors the crawler tier).
``StealthyFetcher.fetch`` is synchronous, so it runs in a worker thread to
keep the event loop free. Returns ``None`` when the browser tier is
unavailable or still blocked.
"""
if StealthyFetcher is None:
return None
try:
started = time.perf_counter()
page = await asyncio.to_thread(
StealthyFetcher.fetch,
url,
headless=True,
network_idle=True,
solve_cloudflare=True,
proxy=get_proxy_url(),
)
fetch_ms = (time.perf_counter() - started) * 1000
logger.info(
"[youtube][perf] source=html tier=stealthy url=%s status=%s fetch_ms=%.1f",
url,
page.status,
fetch_ms,
)
if page.status == 200:
return page.html_content
logger.warning("HTML GET %s tier=stealthy returned %s", url, page.status)
except Exception as e:
logger.warning("HTML GET %s tier=stealthy failed: %s", url, e)
return None

View file

@ -0,0 +1,812 @@
"""Pure parsing/normalization for YouTube page + InnerTube JSON.
I/O-free and dependency-free (no ``jmespath``/``jsonpath-ng``): all traversal
uses two tiny helpers :func:`find_all` (the ``$..key`` equivalent, same style
as ``_extract_playlist_video_ids`` in ``app/routes/youtube_routes.py``) and
:func:`dig` (null-safe positional get). Ported from the Scrapfly reference
``references/scrapfly-scrapers/youtube-scraper/youtube.py`` with all brittle deep
jmespath paths rewritten and the throwing ``convert_to_number`` replaced by a
robust :func:`parse_count`.
"""
from __future__ import annotations
import json
import re
from typing import Any
from urllib.parse import parse_qs, urlparse
# ---------------------------------------------------------------------------
# Traversal helpers
# ---------------------------------------------------------------------------
def find_all(obj: Any, key: str) -> list[Any]:
"""Collect every value stored under ``key`` anywhere in a nested structure."""
out: list[Any] = []
def _walk(node: Any) -> None:
if isinstance(node, dict):
for k, v in node.items():
if k == key:
out.append(v)
_walk(v)
elif isinstance(node, list):
for item in node:
_walk(item)
_walk(obj)
return out
def find_first(obj: Any, key: str) -> Any:
"""First value stored under ``key`` anywhere in the structure, else ``None``."""
matches = find_all(obj, key)
return matches[0] if matches else None
def find_last(obj: Any, key: str) -> Any:
"""Last value stored under ``key`` (continuation tokens want the newest)."""
matches = find_all(obj, key)
return matches[-1] if matches else None
def dig(obj: Any, *path: Any) -> Any:
"""Null-safe positional get through dict keys / list indices."""
cur = obj
for step in path:
if isinstance(step, int) and isinstance(cur, list):
if -len(cur) <= step < len(cur):
cur = cur[step]
else:
return None
elif isinstance(cur, dict):
cur = cur.get(step)
else:
return None
if cur is None:
return None
return cur
# ---------------------------------------------------------------------------
# Value normalization
# ---------------------------------------------------------------------------
_COUNT_RE = re.compile(r"([\d,.]+)\s*([KMB]?)", re.IGNORECASE)
_MULTIPLIER = {"": 1, "K": 1_000, "M": 1_000_000, "B": 1_000_000_000}
def parse_count(value: Any) -> int | None:
"""Normalize a YouTube count string to an int, else ``None``.
Handles ``"451K views"``, ``"1.2M"``, ``"1,234"``, plain ints, and returns
``None`` for ``"No views"`` / missing (the reference's ``convert_to_number``
throws on all of these).
"""
if value is None:
return None
if isinstance(value, (int, float)):
return int(value)
if not isinstance(value, str):
return None
match = _COUNT_RE.search(value.strip())
if not match:
return None
number, suffix = match.group(1), match.group(2).upper()
number = number.replace(",", "")
if number in ("", "."):
return None
try:
return int(float(number) * _MULTIPLIER[suffix])
except (ValueError, KeyError):
return None
def parse_date(microformat: dict | None) -> str | None:
"""Prefer the real publish date from a video's microformat renderer.
``playerMicroformatRenderer.publishDate`` is an actual date (``2024-08-27``),
unlike the relative ``"7 days ago"`` strings on list pages.
"""
if not microformat:
return None
return microformat.get("publishDate") or microformat.get("uploadDate")
def seconds_to_duration(seconds: Any) -> str | None:
"""Format a length in seconds as ``HH:MM:SS`` (Apify ``duration`` shape)."""
total = parse_count(seconds)
if total is None:
return None
hours, rem = divmod(total, 3600)
minutes, secs = divmod(rem, 60)
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
def _best_thumbnail(thumbnails: Any) -> str | None:
"""Return the highest-resolution thumbnail URL from a thumbnails list."""
if not isinstance(thumbnails, list) or not thumbnails:
return None
best = None
best_area = -1
for t in thumbnails:
if not isinstance(t, dict) or "url" not in t:
continue
area = (t.get("width") or 0) * (t.get("height") or 0)
if area >= best_area:
best_area = area
best = t["url"]
return best
# ---------------------------------------------------------------------------
# Embedded page-JSON extraction (brace matcher, from youtube_routes.py)
# ---------------------------------------------------------------------------
def _extract_json_object(html: str, patterns: list[re.Pattern[str]]) -> dict | None:
start = -1
for pattern in patterns:
match = pattern.search(html)
if match:
start = match.end()
break
if start == -1:
return None
depth = 0
i = start
while i < len(html):
ch = html[i]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
break
elif ch == '"':
i += 1
while i < len(html) and html[i] != '"':
if html[i] == "\\":
i += 1
i += 1
i += 1
try:
return json.loads(html[start : i + 1])
except (json.JSONDecodeError, IndexError):
return None
def extract_yt_initial_data(html: str) -> dict | None:
"""Extract the ``ytInitialData`` object embedded in a YouTube page."""
return _extract_json_object(
html,
[
re.compile(r"var\s+ytInitialData\s*=\s*"),
re.compile(r'window\["ytInitialData"\]\s*=\s*'),
],
)
def extract_yt_initial_player_response(html: str) -> dict | None:
"""Extract the ``ytInitialPlayerResponse`` object from a video page."""
return _extract_json_object(
html,
[
re.compile(r"var\s+ytInitialPlayerResponse\s*=\s*"),
re.compile(r'window\["ytInitialPlayerResponse"\]\s*=\s*'),
],
)
# ---------------------------------------------------------------------------
# Page / API parsers -> partial VideoItem dicts
# ---------------------------------------------------------------------------
def _unwrap_redirect(url: str) -> str:
"""Resolve a ``youtube.com/redirect?...&q=<target>`` link to its target."""
if "/redirect" in url and "q=" in url:
q = parse_qs(urlparse(url).query).get("q")
if q:
return q[0]
if url.startswith("/"):
return f"https://www.youtube.com{url}"
return url
def parse_description_links(initial: dict) -> list[dict[str, str]] | None:
"""Extract ``{url, text}`` links from the video's attributed description.
Each ``commandRun`` marks a linked span (``startIndex``/``length``) into the
description ``content``; external links are wrapped in a YouTube redirect
whose real target is the ``q`` query param.
"""
ad = find_first(initial, "attributedDescription")
if not isinstance(ad, dict):
return None
content = ad.get("content") or ""
# YouTube's startIndex/length are UTF-16 code-unit offsets (JS string
# semantics); emoji in the description are surrogate pairs, so slice on the
# UTF-16-LE bytes to keep spans aligned instead of Python code points.
units = content.encode("utf-16-le")
out: list[dict[str, str]] = []
for run in ad.get("commandRuns") or []:
start, length = run.get("startIndex"), run.get("length")
if not isinstance(start, int) or not isinstance(length, int):
continue
cmd = dig(run, "onTap", "innertubeCommand")
url = dig(cmd, "urlEndpoint", "url") or dig(
cmd, "commandMetadata", "webCommandMetadata", "url"
)
if not url:
continue
text = units[start * 2 : (start + length) * 2].decode("utf-16-le", "ignore")
out.append({"url": _unwrap_redirect(url), "text": text})
return out or None
def _comments_turned_off(initial: dict) -> bool | None:
"""Detect a disabled comments section, else ``None`` if no section is present.
An enabled section carries a ``continuationItemRenderer`` (the token that
loads the comments); a disabled one keeps the section but drops the token.
"""
sections = [
s
for s in find_all(initial, "itemSectionRenderer")
if isinstance(s, dict) and s.get("sectionIdentifier") == "comment-item-section"
]
if not sections:
return None
return not any(find_all(s, "continuationItemRenderer") for s in sections)
def _is_members_only(player: dict, initial: dict) -> bool:
"""True for members-only videos (a members badge or a members-only offer)."""
for badge in find_all(initial, "metadataBadgeRenderer"):
if isinstance(badge, dict) and badge.get("style") == "BADGE_STYLE_TYPE_MEMBERS_ONLY":
return True
status = player.get("playabilityStatus") or {}
return find_first(status, "offerId") == "sponsors_only_video"
def _is_age_restricted(player: dict, microformat: dict) -> bool | None:
"""True when the player response signals an age gate, else ``None``.
``playabilityStatus`` is authoritative (age-gated videos report a legacy age
gate reason or an "age"-mentioning reason); ``isFamilySafe`` is the fallback.
"""
status = player.get("playabilityStatus")
if isinstance(status, dict):
reason = (status.get("reason") or "").lower()
if status.get("desktopLegacyAgeGateReason") or "age" in reason:
return True
if microformat.get("isFamilySafe") is False:
return True
return None
def _channel_from_byline(renderer: dict) -> dict[str, Any]:
"""Pull channel name/id/url from a list item's owner byline runs.
Search ``videoRenderer``s carry the uploader in ``ownerText`` /
``longBylineText``; the run's ``browseEndpoint`` holds the channel id and the
canonical ``/@handle`` (or ``/channel/UC...``) url.
"""
run = dig(renderer, "ownerText", "runs", 0) or dig(
renderer, "longBylineText", "runs", 0
)
if not isinstance(run, dict):
return {}
browse = dig(run, "navigationEndpoint", "browseEndpoint") or {}
cid = browse.get("browseId")
base = browse.get("canonicalBaseUrl")
out: dict[str, Any] = {}
if run.get("text"):
out["channelName"] = run["text"]
if cid:
out["channelId"] = cid
if isinstance(base, str) and base:
out["channelUrl"] = f"https://www.youtube.com{base}"
if base.startswith("/@"):
out["channelUsername"] = base[2:] or None
elif cid:
out["channelUrl"] = f"https://www.youtube.com/channel/{cid}"
return out
def parse_channel_metadata(initial_data: dict) -> dict[str, Any]:
"""Channel identity from ``channelMetadataRenderer`` (in the channel HTML).
Zero extra fetch: name/id/url/avatar/description are already in the channel
page's ``ytInitialData`` and apply to every video the channel flow yields.
"""
meta = find_first(initial_data, "channelMetadataRenderer")
if not isinstance(meta, dict):
return {}
out: dict[str, Any] = {}
if meta.get("title"):
out["channelName"] = meta["title"]
if meta.get("externalId"):
out["channelId"] = meta["externalId"]
if meta.get("description"):
out["channelDescription"] = meta["description"]
url = meta.get("vanityChannelUrl") or meta.get("channelUrl")
if url:
out["channelUrl"] = url
avatar = _best_thumbnail(dig(meta, "avatar", "thumbnails"))
if avatar:
out["channelAvatarUrl"] = avatar
banner = _best_thumbnail(
dig(
find_first(initial_data, "pageHeaderViewModel"),
"banner",
"imageBannerViewModel",
"image",
"sources",
)
)
if banner:
out["channelBannerUrl"] = banner
return out
def channel_about_tokens(initial_data: dict) -> list[str]:
"""Continuation tokens for the channel's engagement panels (one is About).
The About panel isn't embedded in the channel HTML; it's fetched via a
``/browse`` continuation. Panels aren't labeled, so callers try each token
and keep the response that yields an ``aboutChannelViewModel``.
"""
tokens: list[str] = []
for ep in find_all(initial_data, "showEngagementPanelEndpoint"):
token = dig(find_first(ep, "continuationCommand"), "token")
if token:
tokens.append(token)
return tokens
def parse_channel_about(about: dict) -> dict[str, Any]:
"""Deep channel fields from an ``aboutChannelViewModel``."""
out: dict[str, Any] = {}
if about.get("description"):
out["channelDescription"] = about["description"]
if about.get("country"):
out["channelLocation"] = about["country"]
subs = parse_count(about.get("subscriberCountText"))
if subs is not None:
out["numberOfSubscribers"] = subs
views = parse_count(about.get("viewCountText"))
if views is not None:
out["channelTotalViews"] = views
videos = parse_count(about.get("videoCountText"))
if videos is not None:
out["channelTotalVideos"] = videos
joined = dig(about, "joinedDateText", "content") or about.get("joinedDateText")
if isinstance(joined, str) and joined:
# "Joined Feb 8, 2005" -> keep the date portion.
out["channelJoinedDate"] = joined.replace("Joined", "").strip()
return out
# The primary-info super-title links to a geo-restricted search for tagged
# videos; its a11y label is the reliable "is this a location?" signal (the same
# slot holds hashtags otherwise). The label is English (hl=en client).
_GEO_TAG_RE = re.compile(r"geo tagged with (.+)", re.IGNORECASE)
def parse_location(initial: dict) -> str | None:
"""Video geo-tag (place name) from ``videoPrimaryInfoRenderer.superTitleLink``.
Returns ``None`` when the super-title is hashtags (or absent) rather than a
location. The place name is read from the a11y label ("...geo tagged with
Rome"), which is proper-cased where the visible runs are upper-cased.
"""
st = dig(find_first(initial, "videoPrimaryInfoRenderer"), "superTitleLink")
if not isinstance(st, dict):
return None
label = dig(st, "accessibility", "accessibilityData", "label") or ""
match = _GEO_TAG_RE.search(label)
return match.group(1).strip() if match else None
def parse_translation(next_data: dict) -> tuple[str | None, str | None]:
"""(title, description) from a ``/next`` response fetched with a target ``hl``.
YouTube renders the creator-localized title/description into
``videoPrimaryInfoRenderer`` / ``videoSecondaryInfoRenderer`` for the request
language. Videos without a localization return their original text.
"""
vpir = find_first(next_data, "videoPrimaryInfoRenderer") or {}
title = "".join(r.get("text", "") for r in (dig(vpir, "title", "runs") or [])) or None
vsir = find_first(next_data, "videoSecondaryInfoRenderer") or {}
description = dig(vsir, "attributedDescription", "content")
if description is None:
description = (
"".join(r.get("text", "") for r in (dig(vsir, "description", "runs") or []))
or None
)
return title, description
def parse_collaborators(initial: dict) -> list[dict[str, str | None]] | None:
"""Collaborator channels from the multi-owner "Collaborators" dialog.
Collaboration videos replace the owner's plain ``title`` with an
``attributedTitle`` whose tap opens a dialog listing each credited channel
(``listItemViewModel``). Returns ``None`` for ordinary single-owner videos.
"""
owner = dig(find_first(initial, "videoSecondaryInfoRenderer"), "owner", "videoOwnerRenderer")
attributed = owner.get("attributedTitle") if isinstance(owner, dict) else None
if not isinstance(attributed, dict):
return None
# The dialog's own list holds one row per channel. Read those rows directly
# (not find_all) — each row's subscribe button nests its own menu items.
dialog = find_first(attributed, "dialogViewModel")
rows = dig(find_first(dialog, "listViewModel"), "listItems") or []
collaborators: list[dict[str, str | None]] = []
for row in rows:
item = row.get("listItemViewModel") if isinstance(row, dict) else None
name = dig(item, "title", "content")
if not name:
continue
base = dig(find_first(item, "browseEndpoint"), "canonicalBaseUrl")
collaborators.append(
{
"name": name,
"username": base[2:] if isinstance(base, str) and base.startswith("/@") else None,
"url": f"https://www.youtube.com{base}" if isinstance(base, str) and base else None,
}
)
return collaborators or None
def parse_video_page(html: str) -> dict[str, Any] | None:
"""Parse a video/shorts watch page into a partial VideoItem dict.
Merges ``ytInitialPlayerResponse.videoDetails`` (title, views, length,
description, thumbnails) with ``ytInitialData`` (likes, comment count,
channel info) and the microformat renderer (real publish date).
"""
player = extract_yt_initial_player_response(html)
initial = extract_yt_initial_data(html)
if not player:
return None
details = player.get("videoDetails") or {}
microformat = dig(player, "microformat", "playerMicroformatRenderer") or {}
video_id = details.get("videoId")
result: dict[str, Any] = {
"id": video_id,
"url": f"https://www.youtube.com/watch?v={video_id}" if video_id else None,
"title": details.get("title"),
"text": details.get("shortDescription"),
"viewCount": parse_count(details.get("viewCount")),
"duration": seconds_to_duration(details.get("lengthSeconds")),
"date": parse_date(microformat),
"thumbnailUrl": _best_thumbnail(dig(details, "thumbnail", "thumbnails")),
"hashtags": details.get("keywords") or [],
"channelName": details.get("author"),
"channelId": details.get("channelId"),
"isAgeRestricted": _is_age_restricted(player, microformat),
# Monetized ⇔ the player response carries ad slots. ponytail: absence
# isn't proof (a logged-out fetch may omit ads), so False→None, not False.
"isMonetized": bool(player.get("adPlacements") or player.get("playerAds"))
or None,
"isMembersOnly": _is_members_only(player, initial or {}),
# "Includes paid promotion" disclosure overlay.
"isPaidContent": find_first(player, "paidContentOverlayRenderer") is not None,
}
if initial:
# Likes: the LIKE button's label carries the count string.
likes = [
b.get("title")
for b in find_all(initial, "buttonViewModel")
if isinstance(b, dict) and b.get("iconName") == "LIKE"
]
result["likes"] = parse_count(likes[0]) if likes else None
# Comment count lives in the engagement panel contextual info.
contextual = find_first(initial, "contextualInfo")
result["commentsCount"] = parse_count(dig(contextual, "runs", 0, "text"))
result["commentsTurnedOff"] = _comments_turned_off(initial)
result["descriptionLinks"] = parse_description_links(initial)
result["location"] = parse_location(initial)
result["collaborators"] = parse_collaborators(initial)
# Channel handle / URL from the canonical base url ("/@Handle").
base = find_first(initial, "canonicalBaseUrl")
if isinstance(base, str) and base:
result["channelUrl"] = f"https://www.youtube.com{base}"
# Only "/@handle" yields a username; "/channel/UC..." is an id, not a handle.
if base.startswith("/@"):
result["channelUsername"] = base[2:] or None
subs = find_first(initial, "subscriberCountText")
result["numberOfSubscribers"] = parse_count(
subs.get("simpleText") if isinstance(subs, dict) else subs
)
# Verified badge on the channel owner.
badges = find_all(initial, "metadataBadgeRenderer")
result["isChannelVerified"] = any(
isinstance(b, dict) and b.get("tooltip") == "Verified" for b in badges
) or None
return result
def _view_count_from_renderer(renderer: dict) -> int | None:
for key in ("shortViewCountText", "viewCountText"):
node = renderer.get(key)
if isinstance(node, dict):
text = node.get("simpleText") or dig(node, "runs", 0, "text")
count = parse_count(text)
if count is not None:
return count
return None
def parse_search_response(data: dict) -> tuple[list[dict[str, Any]], str | None]:
"""Parse an InnerTube search response into partial VideoItem dicts + token."""
results: list[dict[str, Any]] = []
for r in find_all(data, "videoRenderer"):
if not isinstance(r, dict) or "videoId" not in r:
continue
vid = r["videoId"]
results.append(
{
"id": vid,
"url": f"https://www.youtube.com/watch?v={vid}",
"title": dig(r, "title", "runs", 0, "text"),
"text": dig(
r, "detailedMetadataSnippets", 0, "snippetText", "runs", 0, "text"
),
"date": None, # list pages only expose relative time
"publishedTimeText": dig(r, "publishedTimeText", "simpleText"),
"duration": dig(r, "lengthText", "simpleText"),
"viewCount": _view_count_from_renderer(r),
"thumbnailUrl": _best_thumbnail(dig(r, "thumbnail", "thumbnails")),
**_channel_from_byline(r),
}
)
return results, _continuation_token(data)
def _continuation_token(data: dict) -> str | None:
tokens = [
t.get("token")
for t in find_all(data, "continuationCommand")
if isinstance(t, dict) and t.get("token")
]
return tokens[-1] if tokens else None
def parse_playlist_video_ids(data: dict) -> list[str]:
"""Ordered, de-duped video ids from a playlist ``/browse`` response.
Playlist entries are ``lockupViewModel`` (the old ``playlistVideoRenderer``
is retired); ``contentId`` holds the id. The 11-char guard keeps only videos
and drops any playlist/channel lockup (e.g. the sidebar self-lockup).
"""
seen: set[str] = set()
ids: list[str] = []
for lockup in find_all(data, "lockupViewModel"):
vid = lockup.get("contentId") if isinstance(lockup, dict) else None
if vid and len(vid) == 11 and vid not in seen:
seen.add(vid)
ids.append(vid)
return ids
def parse_channel_videos(data: dict) -> tuple[list[dict[str, Any]], str | None]:
"""Parse an InnerTube channel-videos browse response (richItem lockups)."""
results: list[dict[str, Any]] = []
for item in find_all(data, "richItemRenderer"):
lockup = dig(item, "content", "lockupViewModel")
if not isinstance(lockup, dict):
continue
vid = lockup.get("contentId")
if not vid:
continue
meta = dig(lockup, "metadata", "lockupMetadataViewModel")
rows = dig(meta, "metadata", "contentMetadataViewModel", "metadataRows") or []
parts = dig(rows, 0, "metadataParts") or []
results.append(
{
"id": vid,
"url": f"https://www.youtube.com/watch?v={vid}",
"title": dig(meta, "title", "content"),
"viewCount": parse_count(dig(parts, 0, "text", "content")),
"date": None,
"publishedTimeText": dig(parts, 1, "text", "content"),
"duration": _lockup_duration(lockup),
"thumbnailUrl": _best_thumbnail(
dig(lockup, "contentImage", "thumbnailViewModel", "image", "sources")
),
}
)
return results, _continuation_token(data)
def parse_channel_shorts(data: dict) -> tuple[list[dict[str, Any]], str | None]:
"""Parse a channel Shorts browse/seed response (``shortsLockupViewModel``).
Shorts use a different lockup than videos/streams: the id is on the reel
watch endpoint, and title/views live in ``overlayMetadata``.
"""
results: list[dict[str, Any]] = []
for s in find_all(data, "shortsLockupViewModel"):
if not isinstance(s, dict):
continue
vid = dig(s, "onTap", "innertubeCommand", "reelWatchEndpoint", "videoId")
if not vid:
entity = s.get("entityId") or ""
vid = entity.rsplit("-", 1)[-1] if entity else None
if not vid:
continue
results.append(
{
"id": vid,
"url": f"https://www.youtube.com/shorts/{vid}",
"title": dig(s, "overlayMetadata", "primaryText", "content"),
"viewCount": parse_count(
dig(s, "overlayMetadata", "secondaryText", "content")
),
"date": None,
"thumbnailUrl": _best_thumbnail(
dig(s, "thumbnailViewModel", "image", "sources")
),
}
)
return results, _continuation_token(data)
def _lockup_duration(lockup: dict) -> str | None:
overlays = dig(
lockup, "contentImage", "thumbnailViewModel", "overlays"
) or []
for overlay in overlays:
text = dig(
overlay,
"thumbnailBottomOverlayViewModel",
"badges",
0,
"thumbnailBadgeViewModel",
"text",
)
if text:
return text
return None
# ---------------------------------------------------------------------------
# Comments (/next endpoint)
# ---------------------------------------------------------------------------
def comment_section_token(initial_data: dict) -> str | None:
"""The watch page's comments-section continuation (seeds the /next call)."""
for section in find_all(initial_data, "itemSectionRenderer"):
if (
isinstance(section, dict)
and section.get("sectionIdentifier") == "comment-item-section"
):
token = dig(
find_first(section, "continuationItemRenderer"),
"continuationEndpoint",
"continuationCommand",
"token",
)
if token:
return token
return None
def comment_sort_tokens(data: dict) -> dict[str, str]:
"""Map the comment sort labels ("Top"/"Newest") to their continuations."""
tokens: dict[str, str] = {}
sub = find_first(data, "sortFilterSubMenuRenderer")
for item in dig(sub, "subMenuItems") or []:
title = item.get("title") if isinstance(item, dict) else None
token = dig(item, "serviceEndpoint", "continuationCommand", "token")
if title and token:
tokens[title] = token
return tokens
def _comment_continuation_items(data: dict) -> list[Any]:
items: list[Any] = []
for key in ("reloadContinuationItemsCommand", "appendContinuationItemsAction"):
for action in find_all(data, key):
if isinstance(action, dict):
items += action.get("continuationItems") or []
return items
def comment_next_token(data: dict) -> str | None:
"""Pagination token: the trailing bare ``continuationItemRenderer``.
Reply loaders are nested inside ``commentThreadRenderer``s; the page's own
"load more" token is the last top-level continuation item, so scan the
action's ``continuationItems`` from the end.
"""
for item in reversed(_comment_continuation_items(data)):
if isinstance(item, dict) and "continuationItemRenderer" in item:
token = dig(
item,
"continuationItemRenderer",
"continuationEndpoint",
"continuationCommand",
"token",
)
if token:
return token
return None
def comment_reply_tokens(data: dict) -> dict[str, str]:
"""Map each top-level comment id to its replies continuation token."""
tokens: dict[str, str] = {}
for thread in find_all(data, "commentThreadRenderer"):
cid = dig(thread, "commentViewModel", "commentViewModel", "commentId")
token = dig(
find_first(thread, "continuationItemRenderer"),
"continuationEndpoint",
"continuationCommand",
"token",
)
if cid and token:
tokens[cid] = token
return tokens
def parse_comment_entity(cep: dict) -> dict[str, Any]:
"""Map one ``commentEntityPayload`` to a partial CommentItem dict."""
props = cep.get("properties") or {}
author = cep.get("author") or {}
toolbar = cep.get("toolbar") or {}
return {
"cid": props.get("commentId"),
"comment": dig(props, "content", "content"),
"author": author.get("displayName"),
"publishedTimeText": props.get("publishedTime"),
"voteCount": parse_count(toolbar.get("likeCountNotliked")),
"replyCount": parse_count(toolbar.get("replyCount")),
"authorIsChannelOwner": bool(author.get("isCreator")),
# A creator heart attaches the channel owner's avatar to the toolbar.
"hasCreatorHeart": bool(toolbar.get("creatorThumbnailUrl")),
"type": "comment" if (props.get("replyLevel") or 0) == 0 else "reply",
}
def parse_comment_entities(data: dict) -> list[dict[str, Any]]:
"""All comment payloads in a /next response, in display order."""
return [
parse_comment_entity(cep)
for cep in find_all(data, "commentEntityPayload")
if isinstance(cep, dict)
]
def parse_channel_sort_tokens(initial_data: dict) -> dict[str, str]:
"""Map channel-videos sort labels ("Latest"/"Popular"/"Oldest") to tokens."""
tokens: dict[str, str] = {}
for chip in find_all(initial_data, "chipViewModel"):
if not isinstance(chip, dict):
continue
label = chip.get("text")
token = dig(
chip, "tapCommand", "innertubeCommand", "continuationCommand", "token"
)
if label and token:
tokens[label] = token
return tokens

View file

@ -0,0 +1,210 @@
# ruff: noqa: N815 - field names intentionally mirror the Apify camelCase spec
"""Apify-compatible input/output models for the YouTube scraper.
The models mirror the public Apify "YouTube Scraper" actor spec so the endpoint
can be a drop-in. The MVP populates a reliably-sourced subset; every other field
is accepted (input) or emitted as ``None``/``[]`` (output) so parity is additive.
``VideoItem`` uses ``extra="allow"`` on purpose: the Apify spec explicitly says
"Other properties may be included", and it lets us grow the output shape without
breaking existing consumers.
"""
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
SubtitlesFormat = Literal["srt", "vtt", "xml", "plaintext"]
SortingOrder = Literal["relevance", "rating", "date", "views"]
DateFilter = Literal["hour", "today", "week", "month", "year"]
VideoTypeFilter = Literal["video", "movie"]
LengthFilter = Literal["under4", "between420", "plus20"]
SortVideosBy = Literal["NEWEST", "POPULAR", "OLDEST"]
VideoContentType = Literal["video", "shorts", "stream"]
SortCommentsBy = Literal["TOP_COMMENTS", "NEWEST_FIRST"]
CommentType = Literal["comment", "reply"]
class StartUrl(BaseModel):
"""A single direct URL entry (Apify passes ``{"url": ...}`` objects)."""
model_config = ConfigDict(extra="allow")
url: str
class YouTubeScrapeInput(BaseModel):
"""Full Apify input surface.
``maxResults*`` semantics follow Apify: ``0`` means "fetch none of that type"
(see the ``startUrls`` note in the spec), NOT unlimited. A caller wanting an
unbounded run leaves the value high; the request-time guard lives in the
route, not here.
"""
model_config = ConfigDict(extra="allow")
# Discovery
searchQueries: list[str] = Field(default_factory=list)
startUrls: list[StartUrl] = Field(default_factory=list)
# Per-type caps (independent, applied per search term and per channel)
maxResults: int = Field(default=0, ge=0, le=999999)
maxResultsShorts: int = Field(default=0, ge=0, le=999999)
maxResultStreams: int = Field(default=0, ge=0, le=999999)
# Subtitles
downloadSubtitles: bool = False
subtitlesLanguage: str = "en"
subtitlesFormat: SubtitlesFormat = "srt"
saveSubsToKVS: bool = False
preferAutoGeneratedSubtitles: bool = False
# Search filters
sortingOrder: SortingOrder | None = None
dateFilter: DateFilter | None = None
videoType: VideoTypeFilter | None = None
lengthFilter: LengthFilter | None = None
isHD: bool | None = None
hasSubtitles: bool | None = None
hasCC: bool | None = None
is3D: bool | None = None
isLive: bool | None = None
isBought: bool | None = None
is4K: bool | None = None
is360: bool | None = None
hasLocation: bool | None = None
isHDR: bool | None = None
isVR180: bool | None = None
# Channel/date scoping
oldestPostDate: str | None = None
sortVideosBy: SortVideosBy | None = None
class DescriptionLink(BaseModel):
model_config = ConfigDict(extra="allow")
url: str | None = None
text: str | None = None
class SubtitleTrack(BaseModel):
model_config = ConfigDict(extra="allow")
srtUrl: str | None = None
type: str | None = None
language: str | None = None
srt: str | None = None
class Collaborator(BaseModel):
model_config = ConfigDict(extra="allow")
name: str | None = None
username: str | None = None
url: str | None = None
class VideoItem(BaseModel):
"""Apify output item. Unsourced fields default to ``None``/``[]``.
``extra="allow"`` keeps the contract open (Apify: "Other properties may be
included") so gradually-added fields never break existing consumers.
"""
model_config = ConfigDict(extra="allow")
# Core video
title: str | None = None
id: str | None = None
url: str | None = None
viewCount: int | None = None
date: str | None = None
duration: str | None = None
type: VideoContentType = "video"
thumbnailUrl: str | None = None
input: str | None = None
fromYTUrl: str | None = None
order: int | None = None
# Engagement / content
likes: int | None = None
commentsCount: int | None = None
commentsTurnedOff: bool | None = None
text: str | None = None
descriptionLinks: list[DescriptionLink] | None = None
subtitles: list[SubtitleTrack] | None = None
hashtags: list[str] = Field(default_factory=list)
location: str | None = None
# Translations
translatedTitle: str | None = None
translatedText: str | None = None
# Video flags
isMonetized: bool | None = None
isMembersOnly: bool = False
isPaidContent: bool = False
isAgeRestricted: bool | None = None
collaborators: list[Collaborator] | None = None
# Channel
channelName: str | None = None
channelUrl: str | None = None
channelUsername: str | None = None
channelId: str | None = None
numberOfSubscribers: int | None = None
channelTotalVideos: int | None = None
channelDescription: str | None = None
channelLocation: str | None = None
channelJoinedDate: str | None = None
channelTotalViews: int | None = None
isChannelVerified: bool | None = None
channelBannerUrl: str | None = None
channelAvatarUrl: str | None = None
def to_output(self) -> dict[str, Any]:
"""Serialize to the flat dict shape Apify emits (keeps extras)."""
return self.model_dump(exclude_none=False)
class YouTubeCommentsInput(BaseModel):
"""Apify "YouTube Comments Scraper" input surface.
``maxComments`` counts every emitted item (top-level comments and replies).
Setting ``oldestCommentDate`` forces newest-first sort, matching Apify.
"""
model_config = ConfigDict(extra="allow")
startUrls: list[StartUrl] = Field(default_factory=list)
maxComments: int = Field(default=1, ge=1, le=999999)
sortCommentsBy: SortCommentsBy = "NEWEST_FIRST"
oldestCommentDate: str | None = None
class CommentItem(BaseModel):
"""Apify comment output item (one per top-level comment or reply)."""
model_config = ConfigDict(extra="allow")
author: str | None = None
comment: str | None = None
cid: str | None = None
replyCount: int | None = None
replyToCid: str | None = None
voteCount: int | None = None
authorIsChannelOwner: bool | None = None
publishedTimeText: str | None = None
type: CommentType | None = None
hasCreatorHeart: bool | None = None
videoId: str | None = None
pageUrl: str | None = None
commentsCount: int | None = None
title: str | None = None
def to_output(self) -> dict[str, Any]:
return self.model_dump(exclude_none=False)

View file

@ -0,0 +1,493 @@
"""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}")
)
if not data:
return
# ponytail: reads the first browse page only (~100 items). Upgrade path:
# follow the continuation token for playlists longer than one page.
video_ids = parse_playlist_video_ids(data)
for order, vid in enumerate(video_ids):
if order >= limit:
return
async for item in _video_flow(
vid,
input_model=input_model,
source_input=source_input,
from_url=source_input,
order=order,
content_type="video",
):
yield item
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 in ("search", "hashtag"):
# ponytail: hashtag pages have their own layout; MVP routes the tag
# through search (query "#tag"). Upgrade path: dedicated hashtag browse.
query = resolved.value if resolved.kind == "search" else f"#{resolved.value}"
async for item in _search_flow(
query, 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

View file

@ -0,0 +1,92 @@
"""Encode Apify search filters into YouTube's ``sp=`` (InnerTube ``params``).
YouTube encodes search filters as a base64 protobuf. The message is::
SearchParams {
1: sortOrder (varint enum)
2: Filters {
1: uploadDate (varint enum)
2: type (varint enum)
3: duration (varint enum)
<feature fields>: bool (varint 1)
}
}
Rather than a lookup table of single tokens, we build the protobuf directly so
arbitrary combinations (sort + date + type + length + any boolean features)
compose correctly. The field numbers/enums below are verified to reproduce
YouTube's own standalone tokens byte-for-byte (see the unit test).
"""
from __future__ import annotations
import base64
_SORT_ORDER = {"relevance": 0, "rating": 1, "date": 2, "views": 3}
_UPLOAD_DATE = {"hour": 1, "today": 2, "week": 3, "month": 4, "year": 5}
_TYPE = {"video": 1, "channel": 2, "playlist": 3, "movie": 4}
_DURATION = {"under4": 1, "plus20": 2, "between420": 3}
# Apify boolean flag -> Filters feature field number.
_FEATURES = {
"isHD": 4,
"hasSubtitles": 5,
"hasCC": 6,
"is3D": 7,
"isLive": 8,
"isBought": 9,
"is4K": 14,
"is360": 15,
"hasLocation": 23,
"isHDR": 25,
"isVR180": 26,
}
def _varint(value: int) -> bytes:
out = bytearray()
while True:
byte = value & 0x7F
value >>= 7
out.append(byte | (0x80 if value else 0))
if not value:
return bytes(out)
def _tag_varint(field: int, value: int) -> bytes:
"""A protobuf ``field: varint`` entry (wire type 0)."""
return _varint(field << 3) + _varint(value)
def _tag_message(field: int, data: bytes) -> bytes:
"""A protobuf ``field: message`` entry (wire type 2, length-delimited)."""
return _varint((field << 3) | 2) + _varint(len(data)) + data
def build_search_params(input_model) -> str | None:
"""Encode the input's filters into a base64 ``sp=`` token, or ``None``.
All set filters compose into a single protobuf; ``None`` when nothing is set
(so the caller omits ``params`` entirely).
"""
filters = bytearray()
if input_model.dateFilter in _UPLOAD_DATE:
filters += _tag_varint(1, _UPLOAD_DATE[input_model.dateFilter])
if input_model.videoType in _TYPE:
filters += _tag_varint(2, _TYPE[input_model.videoType])
if input_model.lengthFilter in _DURATION:
filters += _tag_varint(3, _DURATION[input_model.lengthFilter])
for flag, field in _FEATURES.items():
if getattr(input_model, flag, None):
filters += _tag_varint(field, 1)
message = bytearray()
sort = _SORT_ORDER.get(input_model.sortingOrder or "")
if sort: # relevance (0) is the default, so it needs no bytes
message += _tag_varint(1, sort)
if filters:
message += _tag_message(2, bytes(filters))
if not message:
return None
return base64.b64encode(bytes(message)).decode("ascii")

View file

@ -0,0 +1,98 @@
"""Subtitle download via ``youtube-transcript-api``, shaped to Apify ``subtitles[]``.
Uses the library's built-in formatters (no hand-rolled srt/vtt conversion). The
transcript API has no XML formatter, so ``xml`` falls back to the raw snippet
data. Fetch/proxy wiring mirrors ``youtube_processor.py``.
"""
from __future__ import annotations
import asyncio
import logging
from fake_useragent import UserAgent
from requests import Session
from youtube_transcript_api import YouTubeTranscriptApi
from youtube_transcript_api.formatters import (
SRTFormatter,
TextFormatter,
WebVTTFormatter,
)
from app.utils.proxy import get_requests_proxies
from .schemas import SubtitleTrack
logger = logging.getLogger(__name__)
_FORMATTERS = {
"srt": SRTFormatter,
"vtt": WebVTTFormatter,
"plaintext": TextFormatter,
}
def _build_client() -> YouTubeTranscriptApi:
http_client = Session()
http_client.headers.update({"User-Agent": UserAgent().random})
proxies = get_requests_proxies()
if proxies:
http_client.proxies.update(proxies)
return YouTubeTranscriptApi(http_client=http_client)
def _select_transcript(transcript_list, language: str, prefer_generated: bool):
"""Pick a transcript honoring language + generated/manual preference."""
# ``any`` means take whatever the video offers first.
if language == "any":
return next(iter(transcript_list))
codes = [language]
if prefer_generated:
try:
return transcript_list.find_generated_transcript(codes)
except Exception:
return transcript_list.find_transcript(codes)
return transcript_list.find_transcript(codes)
def _fetch_subtitles_sync(video_id: str, language: str, fmt: str, prefer_generated: bool):
api = _build_client()
transcript_list = api.list(video_id)
transcript = _select_transcript(transcript_list, language, prefer_generated)
fetched = transcript.fetch()
if fmt == "xml":
# No XML formatter in the library; emit the raw snippet data as text.
body = str(fetched.to_raw_data())
else:
formatter_cls = _FORMATTERS.get(fmt, SRTFormatter)
body = formatter_cls().format_transcript(fetched)
return SubtitleTrack(
srtUrl=None,
type="auto_generated" if transcript.is_generated else "user_generated",
language=transcript.language_code,
srt=body,
)
async def fetch_subtitles(
video_id: str,
*,
language: str = "en",
fmt: str = "srt",
prefer_generated: bool = False,
) -> list[SubtitleTrack] | None:
"""Return the Apify ``subtitles[]`` list for a video, or ``None`` if none.
Runs the blocking transcript API in a worker thread to stay async-friendly.
"""
try:
track = await asyncio.to_thread(
_fetch_subtitles_sync, video_id, language, fmt, prefer_generated
)
return [track]
except Exception as e:
logger.info("No subtitles for video %s (%s): %s", video_id, language, e)
return None

View file

@ -0,0 +1,85 @@
"""Classify a YouTube URL and extract its identifier.
Covers the ``startUrls`` shapes the Apify spec accepts: video, channel,
playlist, hashtag, and search-results pages. Video-ID extraction reuses the
logic already in ``app/tasks/document_processors/youtube_processor.py``.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Literal
from urllib.parse import parse_qs, unquote, urlparse
ResolvedKind = Literal["video", "channel", "playlist", "hashtag", "search"]
_PLAYLIST_ID_RE = re.compile(r"[?&]list=([\w-]+)")
@dataclass(frozen=True)
class ResolvedUrl:
kind: ResolvedKind
value: str # video id, channel handle/id, playlist id, hashtag, or query
url: str
def get_youtube_video_id(url: str) -> str | None:
"""Extract a video ID from watch/youtu.be/embed/shorts URL formats."""
parsed = urlparse(url)
hostname = parsed.hostname or ""
if hostname == "youtu.be":
return parsed.path[1:] or None
if hostname in ("www.youtube.com", "youtube.com", "m.youtube.com"):
if parsed.path == "/watch":
return parse_qs(parsed.query).get("v", [None])[0]
for prefix in ("/embed/", "/v/", "/shorts/"):
if parsed.path.startswith(prefix):
return parsed.path[len(prefix) :].split("/")[0] or None
return None
def resolve_url(url: str) -> ResolvedUrl | None:
"""Classify a YouTube URL into a scrape job, or ``None`` if unrecognized."""
parsed = urlparse(url)
path = parsed.path or ""
# Shorts are videos with their own path.
if "/shorts/" in path:
vid = path.split("/shorts/")[1].split("/")[0]
return ResolvedUrl("video", vid, url) if vid else None
video_id = get_youtube_video_id(url)
if video_id:
return ResolvedUrl("video", video_id, url)
# Playlist (either a /playlist page or any URL carrying ?list=).
playlist_match = _PLAYLIST_ID_RE.search(url)
if path.startswith("/playlist") and playlist_match:
return ResolvedUrl("playlist", playlist_match.group(1), url)
# Search results page.
if path == "/results":
query = parse_qs(parsed.query).get("search_query", [None])[0]
if query:
return ResolvedUrl("search", unquote(query), url)
return None
# Hashtag page (/hashtag/<tag>).
if path.startswith("/hashtag/"):
tag = path[len("/hashtag/") :].split("/")[0]
return ResolvedUrl("hashtag", unquote(tag), url) if tag else None
# Channel: /@handle, /channel/UC..., /c/Name, /user/Name.
if path.startswith("/@"):
return ResolvedUrl("channel", path[2:].split("/")[0], url)
for prefix in ("/channel/", "/c/", "/user/"):
if path.startswith(prefix):
handle = path[len(prefix) :].split("/")[0]
return ResolvedUrl("channel", handle, url) if handle else None
# A trailing ?list= without an explicit /playlist path.
if playlist_match:
return ResolvedUrl("playlist", playlist_match.group(1), url)
return None

View file

@ -0,0 +1,198 @@
"""Manual functional e2e for the YouTube scraper (app/scrapers/youtube).
Run from the backend directory:
cd surfsense_backend
uv run python scripts/e2e_youtube_scraper.py
# or: .\\.venv\\Scripts\\python.exe scripts/e2e_youtube_scraper.py
This is NOT a pytest test (it needs live network + optional proxy creds). It:
Step 0 validates the keyless InnerTube ``search`` POST works; if it 400s the
scraper transparently retries with the public web key (proven here).
Step 1 scrapes a known video URL (metadata + optional subtitles).
Step 2 runs a search query and prints the first few results.
Step 3 scrapes a small channel's latest videos.
Step 4 dumps trimmed raw ytInitialData / ytInitialPlayerResponse fixtures to
tests/unit/scrapers/youtube/fixtures/ for the offline parser test.
"""
import asyncio
import json
import sys
from pathlib import Path
from dotenv import load_dotenv
# --- bootstrap: load .env and put the backend root on sys.path before app.* ---
_BACKEND_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(_BACKEND_ROOT))
for _candidate in (_BACKEND_ROOT / ".env", _BACKEND_ROOT.parent / ".env"):
if _candidate.exists():
load_dotenv(_candidate)
break
from app.scrapers.youtube import ( # noqa: E402
YouTubeCommentsInput,
YouTubeScrapeInput,
scrape_comments,
scrape_youtube,
)
from app.scrapers.youtube.innertube import ( # noqa: E402
INNERTUBE_PUBLIC_API_KEY,
INNERTUBE_SEARCH_URL,
build_innertube_payload,
fetch_html,
post_innertube,
)
from app.scrapers.youtube.parsers import ( # noqa: E402
extract_yt_initial_data,
extract_yt_initial_player_response,
)
_VIDEO_URL = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
_SEARCH_TERM = "web scraping tutorials"
_CHANNEL_URL = "https://www.youtube.com/@YouTube"
# A geo-tagged walking tour + a multi-owner collaboration video (long-tail fields).
_LOCATION_VIDEO_URL = "https://www.youtube.com/watch?v=bhJU_fVHMmY"
_COLLAB_VIDEO_URL = "https://www.youtube.com/watch?v=AI2BwwLX_7s"
# MrBeast localizes titles/descriptions into many languages (translation flow).
_TRANSLATED_VIDEO_URL = "https://www.youtube.com/watch?v=iYlODtkyw_I"
_FIXTURE_DIR = _BACKEND_ROOT / "tests" / "unit" / "scrapers" / "youtube" / "fixtures"
def _hr(title: str) -> None:
print(f"\n{'=' * 70}\n{title}\n{'=' * 70}")
def _check(label: str, ok: bool, detail: str = "") -> bool:
print(f" [{'PASS' if ok else 'FAIL'}] {label}{f'{detail}' if detail else ''}")
return ok
async def step0_validate_innertube() -> bool:
_hr("STEP 0 — InnerTube search POST (keyless, then public-key fallback)")
payload = build_innertube_payload(search_query=_SEARCH_TERM)
keyless = await post_innertube(INNERTUBE_SEARCH_URL, payload)
if keyless is not None:
return _check("keyless search POST", True, "keyless works")
keyed = await post_innertube(
INNERTUBE_SEARCH_URL, payload, api_key=INNERTUBE_PUBLIC_API_KEY
)
return _check("public-key search POST", keyed is not None, "keyless 400 -> key")
async def step1_video() -> bool:
_hr("STEP 1 — scrape a known video")
inp = YouTubeScrapeInput(startUrls=[{"url": _VIDEO_URL}], downloadSubtitles=True)
items = await scrape_youtube(inp)
print(json.dumps(items[:1], indent=2)[:2000])
ok = bool(items) and items[0].get("id") == "dQw4w9WgXcQ"
return _check("video scraped with id + title", ok and bool(items[0].get("title")))
async def step2_search() -> bool:
_hr("STEP 2 — search query")
inp = YouTubeScrapeInput(searchQueries=[_SEARCH_TERM], maxResults=5)
items = await scrape_youtube(inp)
for it in items[:5]:
print(f" - {it.get('id')} | {it.get('title')}")
return _check("search returned results", len(items) > 0, f"{len(items)} items")
async def step3_channel() -> bool:
_hr("STEP 3 — channel latest videos")
inp = YouTubeScrapeInput(startUrls=[{"url": _CHANNEL_URL}], maxResults=3)
items = await scrape_youtube(inp)
for it in items[:3]:
print(f" - {it.get('id')} | {it.get('title')}")
return _check("channel returned videos", len(items) > 0, f"{len(items)} items")
async def step4_dump_fixtures() -> bool:
_hr("STEP 4 — dump raw fixtures for offline test")
html = await fetch_html(_VIDEO_URL)
if not html:
return _check("fetched video HTML", False)
initial = extract_yt_initial_data(html)
player = extract_yt_initial_player_response(html)
_FIXTURE_DIR.mkdir(parents=True, exist_ok=True)
if player:
(_FIXTURE_DIR / "video_player_response.json").write_text(
json.dumps(player), encoding="utf-8"
)
if initial:
(_FIXTURE_DIR / "video_initial_data.json").write_text(
json.dumps(initial), encoding="utf-8"
)
return _check(
"dumped fixtures", bool(player), f"-> {_FIXTURE_DIR}"
)
async def step5_comments() -> bool:
_hr("STEP 5 — comments (+ replies) for a video")
inp = YouTubeCommentsInput(
startUrls=[{"url": _VIDEO_URL}], maxComments=6, sortCommentsBy="NEWEST_FIRST"
)
items = await scrape_comments(inp)
for it in items[:6]:
print(f" - [{it.get('type')}] {it.get('author')} | {it.get('voteCount')} votes")
ok = bool(items) and all(it.get("cid") and it.get("videoId") for it in items)
has_reply = any(it.get("type") == "reply" and it.get("replyToCid") for it in items)
return _check("comments scraped (cid+videoId, reply linkage)", ok and has_reply,
f"{len(items)} items")
async def step6_location_collaborators() -> bool:
_hr("STEP 6 — long-tail fields: location + collaborators")
loc_items = await scrape_youtube(
YouTubeScrapeInput(startUrls=[{"url": _LOCATION_VIDEO_URL}])
)
location = loc_items[0].get("location") if loc_items else None
print(f" location: {location!r}")
collab_items = await scrape_youtube(
YouTubeScrapeInput(startUrls=[{"url": _COLLAB_VIDEO_URL}])
)
collaborators = collab_items[0].get("collaborators") if collab_items else None
print(f" collaborators: {collaborators}")
return _check(
"location + collaborators populated",
bool(location) and bool(collaborators) and len(collaborators) >= 2,
)
async def step7_translation() -> bool:
_hr("STEP 7 — translatedTitle/translatedText (subtitlesLanguage=es)")
items = await scrape_youtube(
YouTubeScrapeInput(startUrls=[{"url": _TRANSLATED_VIDEO_URL}], subtitlesLanguage="es")
)
it = items[0] if items else {}
print(f" title : {it.get('title')}")
print(f" translatedTitle: {it.get('translatedTitle')}")
# A localized video's translated title differs from the canonical English one.
ok = bool(it.get("translatedTitle")) and it.get("translatedTitle") != it.get("title")
return _check("translatedTitle differs from original", ok)
async def main() -> int:
results = []
results.append(await step0_validate_innertube())
if not results[-1]:
print("\nInnerTube unreachable — aborting remaining steps.")
return 1
results.append(await step1_video())
results.append(await step2_search())
results.append(await step3_channel())
results.append(await step4_dump_fixtures())
results.append(await step5_comments())
results.append(await step6_location_collaborators())
results.append(await step7_translation())
_hr("SUMMARY")
print(f" {sum(results)}/{len(results)} steps passed")
return 0 if all(results) else 1
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))

View file

@ -0,0 +1,199 @@
"""Offline resilience tests for the fetch seam and fan-out worker pool.
No network. Stubs the proxy session so the rotate-on-block path (which never
fires in practice because live runs return 200s) is exercised deterministically,
and asserts the worker pool closes every session when the consumer stops early.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
import pytest
from app.scrapers.youtube import innertube, scraper
from app.scrapers.youtube.innertube import (
INNERTUBE_SEARCH_URL,
_current_session,
fetch_html,
post_innertube,
)
class _FakePage:
def __init__(self, status: int) -> None:
self.status = status
def json(self) -> dict:
return {"status": self.status}
@property
def html_content(self) -> str:
return "<html>ok</html>"
class _FakeSession:
"""One 'IP': returns ``status`` for every request, or raises if ``exc``."""
def __init__(self, status: int = 200, *, exc: bool = False) -> None:
self.status = status
self.exc = exc
self.calls = 0
async def post(self, url, json=None): # noqa: A002 - mirror scrapling API
self.calls += 1
if self.exc:
raise ConnectionError("boom")
return _FakePage(self.status)
async def get(self, url, headers=None, cookies=None):
self.calls += 1
if self.exc:
raise ConnectionError("boom")
return _FakePage(self.status)
class _FakeHolder:
"""Holder whose ``rotate()`` advances to the next fake session (a new IP)."""
def __init__(self, sessions: list[_FakeSession]) -> None:
self._sessions = sessions
self.session = sessions[0]
self.rotations = 0
async def rotate(self):
self.rotations += 1
self.session = self._sessions[min(self.rotations, len(self._sessions) - 1)]
return self.session
def _payload() -> dict:
return {"context": {}}
async def test_post_rotates_on_429_then_succeeds():
holder = _FakeHolder([_FakeSession(429), _FakeSession(200)])
token = _current_session.set(holder)
try:
result = await post_innertube(INNERTUBE_SEARCH_URL, _payload())
finally:
_current_session.reset(token)
assert result == {"status": 200}
assert holder.rotations == 1 # rotated exactly once to the healthy IP
async def test_post_rotates_on_connection_error_then_succeeds():
holder = _FakeHolder([_FakeSession(exc=True), _FakeSession(200)])
token = _current_session.set(holder)
try:
result = await post_innertube(INNERTUBE_SEARCH_URL, _payload())
finally:
_current_session.reset(token)
assert result == {"status": 200}
assert holder.rotations == 1
async def test_post_gives_up_after_max_rotations():
# Every IP is blocked -> rotate up to the cap, then return None.
holder = _FakeHolder([_FakeSession(429) for _ in range(innertube._MAX_ROTATIONS + 1)])
token = _current_session.set(holder)
try:
result = await post_innertube(INNERTUBE_SEARCH_URL, _payload())
finally:
_current_session.reset(token)
assert result is None
assert holder.rotations == innertube._MAX_ROTATIONS
async def test_post_does_not_rotate_on_non_block_status():
# 404 is not a block: fail fast, no wasted IP rotations.
holder = _FakeHolder([_FakeSession(404), _FakeSession(200)])
token = _current_session.set(holder)
try:
result = await post_innertube(INNERTUBE_SEARCH_URL, _payload())
finally:
_current_session.reset(token)
assert result is None
assert holder.rotations == 0
async def test_fetch_html_rotates_then_succeeds():
holder = _FakeHolder([_FakeSession(403), _FakeSession(200)])
token = _current_session.set(holder)
try:
html = await fetch_html("https://www.youtube.com/watch?v=abc")
finally:
_current_session.reset(token)
assert html == "<html>ok</html>"
assert holder.rotations == 1
async def test_fetch_html_falls_back_to_stealthy_when_all_blocked(monkeypatch):
called: dict[str, str] = {}
async def _fake_stealthy(url: str):
called["url"] = url
return "<html>stealthy</html>"
monkeypatch.setattr(innertube, "_fetch_html_stealthy", _fake_stealthy)
holder = _FakeHolder([_FakeSession(429) for _ in range(innertube._MAX_ROTATIONS + 1)])
token = _current_session.set(holder)
try:
html = await fetch_html("https://www.youtube.com/watch?v=zzz")
finally:
_current_session.reset(token)
assert html == "<html>stealthy</html>"
assert called["url"].endswith("v=zzz")
class _TrackingHolder:
"""Fake fan-out session holder that records whether it was closed."""
def __init__(self) -> None:
self.closed = False
async def close(self) -> None:
self.closed = True
async def test_fan_out_closes_all_sessions_on_early_stop(monkeypatch):
holders: list[_TrackingHolder] = []
async def _fake_open():
h = _TrackingHolder()
holders.append(h)
return h
# No real session binding needed; jobs just yield.
from contextlib import asynccontextmanager
@asynccontextmanager
async def _fake_bind(_holder):
yield _holder
monkeypatch.setattr(scraper, "open_proxy_holder", _fake_open)
monkeypatch.setattr(scraper, "bind_proxy_holder", _fake_bind)
async def _job(n: int) -> AsyncIterator[dict]:
for i in range(5):
yield {"job": n, "i": i}
jobs = [_job(n) for n in range(20)]
gen = scraper.fan_out(jobs, concurrency=4)
collected = []
async for item in gen:
collected.append(item)
if len(collected) >= 3: # consumer stops early (like a limit)
break
await gen.aclose() # deterministically run fan_out's teardown
assert len(collected) >= 3
assert holders, "workers should have opened sessions"
# Every opened session must be closed after the generator is torn down.
assert all(h.closed for h in holders), "a worker leaked its proxy session"
async def test_fan_out_empty_jobs_is_noop():
out = [x async for x in scraper.fan_out([])]
assert out == []

View file

@ -0,0 +1,852 @@
"""Offline unit tests for the YouTube scraper parsers.
No network. Uses small hand-built raw ``ytInitialData`` / ``ytInitialPlayerResponse``
shapes (mirroring real nesting) plus direct normalization cases the smallest
things that fail if the dict-walk or normalization logic breaks. If the e2e
script has captured real fixtures into ``fixtures/``, they are exercised too.
"""
import base64
import json
from pathlib import Path
import pytest
from app.scrapers.youtube.parsers import (
channel_about_tokens,
comment_next_token,
comment_reply_tokens,
comment_section_token,
comment_sort_tokens,
dig,
find_all,
parse_channel_about,
parse_collaborators,
parse_comment_entities,
parse_description_links,
parse_location,
parse_translation,
parse_channel_metadata,
parse_channel_shorts,
parse_playlist_video_ids,
parse_count,
parse_date,
parse_search_response,
parse_video_page,
seconds_to_duration,
)
from app.scrapers.youtube.schemas import YouTubeScrapeInput
from app.scrapers.youtube.search_filters import build_search_params
from app.scrapers.youtube.url_resolver import resolve_url
pytestmark = pytest.mark.unit
_FIXTURE_DIR = Path(__file__).parent / "fixtures"
# --- normalization -----------------------------------------------------------
@pytest.mark.parametrize(
"raw,expected",
[
("451K views", 451_000),
("1.2M", 1_200_000),
("1,234", 1_234),
("100", 100),
("2.5B", 2_500_000_000),
(500, 500),
("No views", None),
("", None),
(None, None),
],
)
def test_parse_count(raw, expected):
assert parse_count(raw) == expected
def test_parse_date_prefers_publish_date():
mf = {"publishDate": "2024-08-27", "uploadDate": "2024-08-20"}
assert parse_date(mf) == "2024-08-27"
assert parse_date({"uploadDate": "2024-08-20"}) == "2024-08-20"
assert parse_date(None) is None
def test_seconds_to_duration():
assert seconds_to_duration("207") == "00:03:27"
assert seconds_to_duration(3661) == "01:01:01"
assert seconds_to_duration(None) is None
# --- traversal helpers -------------------------------------------------------
def test_find_all_and_dig():
data = {"a": {"b": [{"k": 1}, {"k": 2}]}, "k": 3}
assert find_all(data, "k") == [1, 2, 3]
assert dig(data, "a", "b", 0, "k") == 1
assert dig(data, "a", "b", 5, "k") is None
assert dig(data, "missing", "x") is None
# --- page parsing ------------------------------------------------------------
def _video_html() -> str:
player = {
"videoDetails": {
"videoId": "abc123",
"title": "Test Title",
"lengthSeconds": "207",
"viewCount": "100",
"author": "TechReviewer",
"channelId": "UC123",
"shortDescription": "desc",
"keywords": ["#tech"],
"thumbnail": {
"thumbnails": [
{"url": "https://i.ytimg.com/vi/abc123/hq.jpg", "width": 480, "height": 360}
]
},
},
"microformat": {
"playerMicroformatRenderer": {"publishDate": "2024-08-27", "isFamilySafe": True}
},
"adPlacements": [{"adPlacementRenderer": {}}],
}
initial = {
"like": {"buttonViewModel": {"iconName": "LIKE", "title": "15K"}},
"comments": {
"itemSectionRenderer": {
"sectionIdentifier": "comment-item-section",
"contents": [{"continuationItemRenderer": {"trigger": "x"}}],
}
},
"ctx": {"contextualInfo": {"runs": [{"text": "1,250"}]}},
"chan": {"canonicalBaseUrl": "/@Apify"},
"subs": {"subscriberCountText": {"simpleText": "500K subscribers"}},
"badge": {"metadataBadgeRenderer": {"tooltip": "Verified"}},
}
return (
"<html><script>var ytInitialPlayerResponse = "
+ json.dumps(player)
+ ";</script><script>var ytInitialData = "
+ json.dumps(initial)
+ ";</script></html>"
)
def test_parse_video_page():
result = parse_video_page(_video_html())
assert result is not None
assert result["id"] == "abc123"
assert result["url"] == "https://www.youtube.com/watch?v=abc123"
assert result["title"] == "Test Title"
assert result["viewCount"] == 100
assert result["duration"] == "00:03:27"
assert result["date"] == "2024-08-27"
assert result["thumbnailUrl"] == "https://i.ytimg.com/vi/abc123/hq.jpg"
assert result["hashtags"] == ["#tech"]
assert result["channelName"] == "TechReviewer"
assert result["channelId"] == "UC123"
assert result["likes"] == 15_000
assert result["commentsCount"] == 1_250
assert result["channelUrl"] == "https://www.youtube.com/@Apify"
assert result["channelUsername"] == "Apify"
assert result["numberOfSubscribers"] == 500_000
assert result["isChannelVerified"] is True
assert result["isMonetized"] is True # adPlacements present
assert result["isAgeRestricted"] is None # family safe, no age gate
assert result["commentsTurnedOff"] is False # section has a continuation
def test_parse_video_page_comments_turned_off():
player = {"videoDetails": {"videoId": "x", "title": "t"}}
initial = {
"itemSectionRenderer": {
"sectionIdentifier": "comment-item-section",
"contents": [{"messageRenderer": {"text": {"simpleText": "off"}}}],
}
}
html = (
"<script>var ytInitialPlayerResponse = "
+ json.dumps(player)
+ ";</script><script>var ytInitialData = "
+ json.dumps(initial)
+ ";</script>"
)
result = parse_video_page(html)
assert result is not None
assert result["commentsTurnedOff"] is True
assert result["commentsCount"] is None
def test_parse_video_page_age_restricted():
player = {
"videoDetails": {"videoId": "x", "title": "t"},
"playabilityStatus": {
"status": "LOGIN_REQUIRED",
"desktopLegacyAgeGateReason": 1,
},
}
html = "<script>var ytInitialPlayerResponse = " + json.dumps(player) + ";</script>"
result = parse_video_page(html)
assert result is not None
assert result["isAgeRestricted"] is True
assert result["isMonetized"] is None # no ad slots → can't confirm
def test_parse_video_page_members_only_and_paid():
player = {
"videoDetails": {"videoId": "m", "title": "t"},
"playabilityStatus": {"errorScreen": {"x": {"offerId": "sponsors_only_video"}}},
"paidContentOverlay": {"paidContentOverlayRenderer": {"text": "Includes paid promotion"}},
}
initial = {
"badges": [
{
"metadataBadgeRenderer": {
"style": "BADGE_STYLE_TYPE_MEMBERS_ONLY",
"label": "Members only",
}
}
]
}
html = (
"<script>var ytInitialPlayerResponse = "
+ json.dumps(player)
+ ";</script><script>var ytInitialData = "
+ json.dumps(initial)
+ ";</script>"
)
result = parse_video_page(html)
assert result is not None
assert result["isMembersOnly"] is True
assert result["isPaidContent"] is True
def test_parse_video_page_returns_none_without_player():
assert parse_video_page("<html>no data here</html>") is None
def test_parse_channel_metadata():
initial = {
"metadata": {
"channelMetadataRenderer": {
"title": "Apify",
"externalId": "UCabc123",
"description": "We scrape the web.",
"vanityChannelUrl": "https://www.youtube.com/@Apify",
"avatar": {"thumbnails": [{"url": "https://a/avatar.jpg", "width": 88, "height": 88}]},
}
},
"header": {
"pageHeaderViewModel": {
"banner": {
"imageBannerViewModel": {
"image": {
"sources": [
{"url": "https://a/banner.jpg", "width": 1060, "height": 175}
]
}
}
}
}
},
}
meta = parse_channel_metadata(initial)
assert meta["channelName"] == "Apify"
assert meta["channelId"] == "UCabc123"
assert meta["channelDescription"] == "We scrape the web."
assert meta["channelUrl"] == "https://www.youtube.com/@Apify"
assert meta["channelAvatarUrl"] == "https://a/avatar.jpg"
assert meta["channelBannerUrl"] == "https://a/banner.jpg"
assert parse_channel_metadata({}) == {}
def test_parse_channel_about():
about = {
"description": "Official channel.",
"country": "United States",
"subscriberCountText": "45.6M subscribers",
"viewCountText": "1,132,903,744 views",
"videoCountText": "1,471 videos",
"joinedDateText": {"content": "Joined Feb 8, 2005"},
}
out = parse_channel_about(about)
assert out["channelDescription"] == "Official channel."
assert out["channelLocation"] == "United States"
assert out["numberOfSubscribers"] == 45_600_000
assert out["channelTotalViews"] == 1_132_903_744
assert out["channelTotalVideos"] == 1_471
assert out["channelJoinedDate"] == "Feb 8, 2005"
def test_parse_description_links():
initial = {
"attributedDescription": {
"content": "See #tag and my site here",
"commandRuns": [
{
"startIndex": 4,
"length": 4,
"onTap": {"innertubeCommand": {"urlEndpoint": {"url": "/hashtag/tag"}}},
},
{
"startIndex": 21,
"length": 4,
"onTap": {
"innertubeCommand": {
"commandMetadata": {
"webCommandMetadata": {
"url": "https://www.youtube.com/redirect?q=https%3A%2F%2Fexample.com%2Fp&v=x"
}
}
}
},
},
],
}
}
links = parse_description_links(initial)
assert links == [
{"url": "https://www.youtube.com/hashtag/tag", "text": "#tag"},
{"url": "https://example.com/p", "text": "here"},
]
assert parse_description_links({}) is None
def test_channel_about_tokens():
initial = {
"a": {"showEngagementPanelEndpoint": {"x": {"continuationCommand": {"token": "T1"}}}},
"b": {"showEngagementPanelEndpoint": {"y": {"continuationCommand": {"token": "T2"}}}},
}
assert channel_about_tokens(initial) == ["T1", "T2"]
assert channel_about_tokens({}) == []
def test_parse_search_response():
data = {
"contents": [
{
"videoRenderer": {
"videoId": "mx3g7XoPVNQ",
"title": {"runs": [{"text": "A bad day"}]},
"detailedMetadataSnippets": [
{"snippetText": {"runs": [{"text": "become an engineer"}]}}
],
"publishedTimeText": {"simpleText": "7 days ago"},
"lengthText": {"simpleText": "8:39"},
"shortViewCountText": {"simpleText": "451K views"},
"thumbnail": {
"thumbnails": [
{"url": "https://i.ytimg.com/vi/x/hq.jpg", "width": 360, "height": 202}
]
},
"ownerText": {
"runs": [
{
"text": "Life of Boris",
"navigationEndpoint": {
"browseEndpoint": {
"browseId": "UCS5tt2z_DFvG7-39J3aE-bQ",
"canonicalBaseUrl": "/@LifeofBoris",
}
},
}
]
},
}
}
],
"continuationCommand": {"token": "TOKEN123"},
}
items, token = parse_search_response(data)
assert token == "TOKEN123"
assert len(items) == 1
item = items[0]
assert item["id"] == "mx3g7XoPVNQ"
assert item["title"] == "A bad day"
assert item["viewCount"] == 451_000
assert item["duration"] == "8:39"
assert item["publishedTimeText"] == "7 days ago"
assert item["date"] is None # list pages have no real date
assert item["channelName"] == "Life of Boris"
assert item["channelId"] == "UCS5tt2z_DFvG7-39J3aE-bQ"
assert item["channelUrl"] == "https://www.youtube.com/@LifeofBoris"
assert item["channelUsername"] == "LifeofBoris"
def test_parse_channel_shorts():
data = {
"richItemRenderer": {
"content": {
"shortsLockupViewModel": {
"entityId": "shorts-shelf-item-0UfLo9SOUeE",
"onTap": {
"innertubeCommand": {
"reelWatchEndpoint": {"videoId": "0UfLo9SOUeE"}
}
},
"overlayMetadata": {
"primaryText": {"content": "is the west coast the best?"},
"secondaryText": {"content": "812 views"},
},
"thumbnailViewModel": {
"image": {
"sources": [
{"url": "https://i.ytimg.com/s.jpg", "width": 405, "height": 720}
]
}
},
}
}
},
"continuationCommand": {"token": "SHORTSTOKEN"},
}
items, token = parse_channel_shorts(data)
assert token == "SHORTSTOKEN"
assert len(items) == 1
it = items[0]
assert it["id"] == "0UfLo9SOUeE"
assert it["url"] == "https://www.youtube.com/shorts/0UfLo9SOUeE"
assert it["title"] == "is the west coast the best?"
assert it["viewCount"] == 812
assert it["thumbnailUrl"] == "https://i.ytimg.com/s.jpg"
def test_parse_playlist_video_ids():
# Playlists now return lockupViewModel with contentId (not playlistVideoRenderer).
# Guards against the exact renderer-migration that silently broke the flow:
# keep videos (11-char ids) in order, dedup, and drop non-video lockups.
data = {
"contents": {
"items": [
{"lockupViewModel": {"contentId": "fNk_zzaMoSs", "contentType": "VIDEO"}},
{"lockupViewModel": {"contentId": "k7RM-ot2NWY", "contentType": "VIDEO"}},
{"lockupViewModel": {"contentId": "fNk_zzaMoSs", "contentType": "VIDEO"}}, # dup
]
},
# a playlist self-lockup (non-video, longer id) that must be ignored
"sidebar": {"lockupViewModel": {"contentId": "PLZHQObOWTQDPD3MizzM2xVFitgF8hE_ab"}},
}
assert parse_playlist_video_ids(data) == ["fNk_zzaMoSs", "k7RM-ot2NWY"]
# --- search filter (sp=) encoder --------------------------------------------
@pytest.mark.parametrize(
"kwargs,expected",
[
({}, None),
({"sortingOrder": "relevance"}, None), # default sort → no bytes
({"sortingOrder": "rating"}, "CAE="),
({"sortingOrder": "date"}, "CAI="),
({"sortingOrder": "views"}, "CAM="),
({"videoType": "video"}, "EgIQAQ=="),
({"videoType": "movie"}, "EgIQBA=="),
({"dateFilter": "hour"}, "EgIIAQ=="),
({"dateFilter": "year"}, "EgIIBQ=="),
({"lengthFilter": "under4"}, "EgIYAQ=="),
({"lengthFilter": "between420"}, "EgIYAw=="),
({"lengthFilter": "plus20"}, "EgIYAg=="),
],
)
def test_build_search_params_matches_youtube_tokens(kwargs, expected):
assert build_search_params(YouTubeScrapeInput(**kwargs)) == expected
def test_build_search_params_combines_filters():
# sort=date + upload=week + type=video + HD + subtitles, all in one token.
token = build_search_params(
YouTubeScrapeInput(
sortingOrder="date",
dateFilter="week",
videoType="video",
isHD=True,
hasSubtitles=True,
)
)
raw = base64.b64decode(token)
# top-level: field1 (sort=2), field2 (Filters submessage)
assert raw[:2] == b"\x08\x02" # sort = date
assert b"\x12" in raw # Filters message tag present
# Filters payload contains upload_date=week(3), type=video(1), hd, subtitles
assert b"\x08\x03" in raw # uploadDate = week
assert b"\x10\x01" in raw # type = video
assert b"\x20\x01" in raw # feature 4 (HD) = 1
assert b"\x28\x01" in raw # feature 5 (subtitles) = 1
# --- location & collaborators ------------------------------------------------
def _primary_info(super_title_link):
return {
"contents": {
"twoColumnWatchNextResults": {
"results": {
"results": {
"contents": [
{"videoPrimaryInfoRenderer": {"superTitleLink": super_title_link}}
]
}
}
}
}
}
def test_parse_location_from_geo_tag_label():
initial = _primary_info(
{
"runs": [{"text": "ROME"}],
"accessibility": {
"accessibilityData": {
"label": "Link to a location restricted search for videos geo tagged with Rome"
}
},
}
)
assert parse_location(initial) == "Rome"
def test_parse_location_hashtag_supertitle_is_none():
# Same slot, but hashtags carry no geo-tag a11y label.
initial = _primary_info({"runs": [{"text": "#music"}, {"text": "#video"}]})
assert parse_location(initial) is None
def test_parse_location_absent_is_none():
assert parse_location({}) is None
def _owner(video_owner_renderer):
return {
"contents": {
"twoColumnWatchNextResults": {
"results": {
"results": {
"contents": [
{
"videoSecondaryInfoRenderer": {
"owner": {"videoOwnerRenderer": video_owner_renderer}
}
}
]
}
}
}
}
}
def _collab_row(name, base_url):
return {
"listItemViewModel": {
"title": {
"content": name,
"commandRuns": [
{
"onTap": {
"innertubeCommand": {
"browseEndpoint": {"browseId": "UCx", "canonicalBaseUrl": base_url}
}
}
}
],
},
# A nested subscribe submenu — must NOT be picked up as a collaborator.
"subscribeMenu": {"listItemViewModel": {"title": {"content": "Unsubscribe"}}},
}
}
def test_parse_collaborators_from_dialog():
initial = _owner(
{
"attributedTitle": {
"content": "Alice and Bob",
"commandRuns": [
{
"onTap": {
"innertubeCommand": {
"showDialogCommand": {
"panelLoadingStrategy": {
"inlineContent": {
"dialogViewModel": {
"header": {
"dialogHeaderViewModel": {
"headline": {"content": "Collaborators"}
}
},
"customContent": {
"listViewModel": {
"listItems": [
_collab_row("Alice", "/@alice"),
_collab_row(
"Bob", "/channel/UCbob123"
),
]
}
},
}
}
}
}
}
}
}
],
}
}
)
collaborators = parse_collaborators(initial)
assert collaborators == [
{"name": "Alice", "username": "alice", "url": "https://www.youtube.com/@alice"},
{"name": "Bob", "username": None, "url": "https://www.youtube.com/channel/UCbob123"},
]
def test_parse_collaborators_single_owner_is_none():
# Ordinary videos use title.runs, not attributedTitle.
initial = _owner({"title": {"runs": [{"text": "Some Channel"}]}})
assert parse_collaborators(initial) is None
def test_parse_translation_from_next():
next_data = {
"contents": {
"twoColumnWatchNextResults": {
"results": {
"results": {
"contents": [
{"videoPrimaryInfoRenderer": {"title": {"runs": [{"text": "Título "}, {"text": "traducido"}]}}},
{"videoSecondaryInfoRenderer": {"attributedDescription": {"content": "Descripción traducida"}}},
]
}
}
}
}
}
title, description = parse_translation(next_data)
assert title == "Título traducido"
assert description == "Descripción traducida"
def test_parse_translation_description_runs_fallback():
next_data = {
"videoSecondaryInfoRenderer": {"description": {"runs": [{"text": "old "}, {"text": "style"}]}}
}
title, description = parse_translation(next_data)
assert title is None
assert description == "old style"
# --- comments ----------------------------------------------------------------
def _comment_cep(cid, *, level=0, hearted=False, owner=False, replies="5"):
"""Mirror a real commentEntityPayload's relevant fields."""
toolbar = {"likeCountNotliked": "1.2K", "replyCount": replies}
if hearted:
toolbar["creatorThumbnailUrl"] = "https://yt3.ggpht.com/heart"
return {
"properties": {
"commentId": cid,
"content": {"content": f"text {cid}"},
"publishedTime": "2 days ago",
"replyLevel": level,
},
"author": {"displayName": f"@user{cid}", "isCreator": owner},
"toolbar": toolbar,
}
def _next_comments_response():
"""A /next comments response: sort menu, two threads, a page token."""
def _reply_loader(token):
return {
"continuationItemRenderer": {
"continuationEndpoint": {"continuationCommand": {"token": token}}
}
}
def _thread(cid, reply_token):
return {
"commentThreadRenderer": {
"commentViewModel": {"commentViewModel": {"commentId": cid}},
"replies": {
"commentRepliesRenderer": {"contents": [_reply_loader(reply_token)]}
},
}
}
return {
"frameworkUpdates": {
"entityBatchUpdate": {
"mutations": [
{"payload": {"commentEntityPayload": _comment_cep("C1", hearted=True, owner=True)}},
{"payload": {"commentEntityPayload": _comment_cep("C2")}},
]
}
},
"onResponseReceivedEndpoints": [
{
"reloadContinuationItemsCommand": {
"continuationItems": [
_thread("C1", "REPLYTOK1"),
_thread("C2", "REPLYTOK2"),
{
"continuationItemRenderer": {
"continuationEndpoint": {
"continuationCommand": {"token": "PAGE2"}
}
}
},
]
}
}
],
"header": {
"sortFilterSubMenuRenderer": {
"subMenuItems": [
{"title": "Top", "serviceEndpoint": {"continuationCommand": {"token": "TOPTOK"}}},
{"title": "Newest", "serviceEndpoint": {"continuationCommand": {"token": "NEWTOK"}}},
]
}
},
}
def test_parse_comment_entities():
data = _next_comments_response()
comments = parse_comment_entities(data)
assert len(comments) == 2
first = comments[0]
assert first["cid"] == "C1"
assert first["comment"] == "text C1"
assert first["author"] == "@userC1"
assert first["voteCount"] == 1200 # "1.2K"
assert first["replyCount"] == 5
assert first["type"] == "comment"
assert first["hasCreatorHeart"] is True
assert first["authorIsChannelOwner"] is True
# second is a plain, non-hearted comment
assert comments[1]["hasCreatorHeart"] is False
assert comments[1]["authorIsChannelOwner"] is False
def test_comment_entity_reply_level_and_empty_reply_count():
reply = parse_comment_entities(
{
"frameworkUpdates": {
"entityBatchUpdate": {
"mutations": [
{"payload": {"commentEntityPayload": _comment_cep("R1", level=1, replies="")}}
]
}
}
}
)[0]
assert reply["type"] == "reply"
assert reply["replyCount"] is None # empty string -> None
def test_comment_sort_tokens():
tokens = comment_sort_tokens(_next_comments_response())
assert tokens == {"Top": "TOPTOK", "Newest": "NEWTOK"}
def test_comment_reply_tokens():
tokens = comment_reply_tokens(_next_comments_response())
assert tokens == {"C1": "REPLYTOK1", "C2": "REPLYTOK2"}
def test_comment_next_token_is_trailing_bare_continuation():
# The page token is the last top-level continuationItemRenderer, not a
# reply loader nested inside a thread.
assert comment_next_token(_next_comments_response()) == "PAGE2"
def test_comment_section_token_from_watch_page():
initial = {
"contents": {
"twoColumnWatchNextResults": {
"results": {
"results": {
"contents": [
{
"itemSectionRenderer": {
"sectionIdentifier": "comment-item-section",
"contents": [
{
"continuationItemRenderer": {
"continuationEndpoint": {
"continuationCommand": {"token": "SECTIONTOK"}
}
}
}
],
}
}
]
}
}
}
}
}
assert comment_section_token(initial) == "SECTIONTOK"
# --- url resolution ----------------------------------------------------------
@pytest.mark.parametrize(
"url,kind,value",
[
("https://www.youtube.com/watch?v=dQw4w9WgXcQ", "video", "dQw4w9WgXcQ"),
("https://youtu.be/dQw4w9WgXcQ", "video", "dQw4w9WgXcQ"),
("https://www.youtube.com/shorts/abc123", "video", "abc123"),
("https://www.youtube.com/@Apify", "channel", "Apify"),
("https://www.youtube.com/channel/UC123456789abc/videos", "channel", "UC123456789abc"),
("https://www.youtube.com/playlist?list=PL123", "playlist", "PL123"),
("https://www.youtube.com/results?search_query=web+scraping", "search", "web scraping"),
("https://www.youtube.com/hashtag/tech", "hashtag", "tech"),
],
)
def test_resolve_url(url, kind, value):
resolved = resolve_url(url)
assert resolved is not None
assert resolved.kind == kind
assert resolved.value == value
def test_resolve_url_unrecognized():
assert resolve_url("https://example.com/foo") is None
# --- optional: exercise captured real fixtures if present --------------------
def test_parse_captured_video_fixture_if_present():
player_path = _FIXTURE_DIR / "video_player_response.json"
initial_path = _FIXTURE_DIR / "video_initial_data.json"
if not player_path.exists():
pytest.skip("no captured fixture (run scripts/e2e_youtube_scraper.py first)")
html = (
"<script>var ytInitialPlayerResponse = "
+ player_path.read_text(encoding="utf-8")
+ ";</script>"
)
if initial_path.exists():
html += (
"<script>var ytInitialData = "
+ initial_path.read_text(encoding="utf-8")
+ ";</script>"
)
result = parse_video_page(html)
assert result is not None
assert result["id"]
assert result["title"]