From be9b597144789fa6a1094995318a47b69ee4390a Mon Sep 17 00:00:00 2001 From: kasparasizi1 <132673909+kasparasizi1@users.noreply.github.com> Date: Sun, 21 Jun 2026 21:27:07 +0300 Subject: [PATCH] feat(youtube): add YouTube sub-client (39 endpoints) YoutubeClient (client.youtube) with search/videos/channels/playlists/ comments/transcript/trending/reference sub-clients + full frozen Pydantic models mirroring the backend. Top-level Youtube*-prefixed model exports. Bump 0.15.0 -> 0.15.1. Built on a clean checkout of main (the working repo had unrelated WIP). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QroidVyYE9Wrbx6UGFka3b --- README.md | 1 + pyproject.toml | 2 +- src/scrapebadger/__init__.py | 204 +++++- src/scrapebadger/_internal/client.py | 2 +- src/scrapebadger/client.py | 22 + src/scrapebadger/youtube/__init__.py | 145 +++++ src/scrapebadger/youtube/channels.py | 378 ++++++++++++ src/scrapebadger/youtube/client.py | 211 +++++++ src/scrapebadger/youtube/comments.py | 141 +++++ src/scrapebadger/youtube/models.py | 820 +++++++++++++++++++++++++ src/scrapebadger/youtube/playlists.py | 127 ++++ src/scrapebadger/youtube/reference.py | 112 ++++ src/scrapebadger/youtube/search.py | 219 +++++++ src/scrapebadger/youtube/transcript.py | 103 ++++ src/scrapebadger/youtube/trending.py | 98 +++ src/scrapebadger/youtube/videos.py | 281 +++++++++ 16 files changed, 2863 insertions(+), 3 deletions(-) create mode 100644 src/scrapebadger/youtube/__init__.py create mode 100644 src/scrapebadger/youtube/channels.py create mode 100644 src/scrapebadger/youtube/client.py create mode 100644 src/scrapebadger/youtube/comments.py create mode 100644 src/scrapebadger/youtube/models.py create mode 100644 src/scrapebadger/youtube/playlists.py create mode 100644 src/scrapebadger/youtube/reference.py create mode 100644 src/scrapebadger/youtube/search.py create mode 100644 src/scrapebadger/youtube/transcript.py create mode 100644 src/scrapebadger/youtube/trending.py create mode 100644 src/scrapebadger/youtube/videos.py diff --git a/README.md b/README.md index 760f52d..7a52bb5 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ export SCRAPEBADGER_API_KEY="sb_live_xxxxxxxxxxxxx" | **Reddit** | Search posts, subreddits, users, and domains; fetch post comments, subreddit rules, moderators, wiki pages, user trophies | [Reddit Guide](docs/reddit.md) | | **Amazon** | 14 endpoints — search, autocomplete, product detail, offers, reviews, bestsellers, new releases, deals, category browse, seller profile/products/feedback, markets, categories | [Amazon Guide](docs/amazon.md) | | **eBay** | 12 endpoints across 18 markets — search, completed/sold search, item detail, item reviews, seller profile/items/feedback, category browse, categories, autocomplete, markets | [eBay Guide](docs/ebay.md) | +| **YouTube** | 39 endpoints — search, autocomplete, video detail/related/comments/replies/transcript/captions/streams/live-chat/batch, channel detail + videos/shorts/streams/playlists/community/about/subscriber-count/in-channel-search/resolve, playlists/items/mixes, trending/hashtag/home, shorts, community post/comments, music search, oembed, categories/languages/regions | [YouTube Guide](docs/youtube.md) | | **TikTok** | 25 endpoints — user profile/videos/followers/following/liked/reposts, video detail/comments/replies/related/transcript/oEmbed, search (general/videos/hashtags/users), music detail/videos, hashtag detail/videos, trending videos/hashtags/songs, ad library, regions | [TikTok Guide](docs/tiktok.md) | ## Error Handling diff --git a/pyproject.toml b/pyproject.toml index 006da32..684bd62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "scrapebadger" -version = "0.15.0" +version = "0.15.1" description = "Official Python SDK for ScrapeBadger - Async web scraping APIs for Twitter and more" readme = "README.md" license = { text = "MIT" } diff --git a/src/scrapebadger/__init__.py b/src/scrapebadger/__init__.py index b7770a0..d9d3435 100644 --- a/src/scrapebadger/__init__.py +++ b/src/scrapebadger/__init__.py @@ -268,8 +268,159 @@ async def main(): VintedUserSummary, ) from scrapebadger.web.models import DetectResult, ScrapeResult +from scrapebadger.youtube.client import YoutubeClient +from scrapebadger.youtube.models import ( + AudioTrack as YoutubeAudioTrack, +) +from scrapebadger.youtube.models import ( + AutocompleteResponse as YoutubeAutocompleteResponse, +) +from scrapebadger.youtube.models import ( + BatchResponse as YoutubeBatchResponse, +) +from scrapebadger.youtube.models import ( + CaptionsResponse as YoutubeCaptionsResponse, +) +from scrapebadger.youtube.models import ( + CaptionTrack as YoutubeCaptionTrack, +) +from scrapebadger.youtube.models import ( + CategoriesResponse as YoutubeCategoriesResponse, +) +from scrapebadger.youtube.models import ( + Channel as YoutubeChannel, +) +from scrapebadger.youtube.models import ( + ChannelAbout as YoutubeChannelAbout, +) +from scrapebadger.youtube.models import ( + ChannelLink as YoutubeChannelLink, +) +from scrapebadger.youtube.models import ( + ChannelTabResponse as YoutubeChannelTabResponse, +) +from scrapebadger.youtube.models import ( + ChannelVideosResponse as YoutubeChannelVideosResponse, +) +from scrapebadger.youtube.models import ( + Chapter as YoutubeChapter, +) +from scrapebadger.youtube.models import ( + Comment as YoutubeComment, +) +from scrapebadger.youtube.models import ( + CommentsResponse as YoutubeCommentsResponse, +) +from scrapebadger.youtube.models import ( + CommunityPost as YoutubeCommunityPost, +) +from scrapebadger.youtube.models import ( + CommunityResponse as YoutubeCommunityResponse, +) +from scrapebadger.youtube.models import ( + Format as YoutubeFormat, +) +from scrapebadger.youtube.models import ( + HashtagResponse as YoutubeHashtagResponse, +) +from scrapebadger.youtube.models import ( + HeatMarker as YoutubeHeatMarker, +) +from scrapebadger.youtube.models import ( + HomeResponse as YoutubeHomeResponse, +) +from scrapebadger.youtube.models import ( + LanguagesResponse as YoutubeLanguagesResponse, +) +from scrapebadger.youtube.models import ( + LiveChatMessage as YoutubeLiveChatMessage, +) +from scrapebadger.youtube.models import ( + LiveChatResponse as YoutubeLiveChatResponse, +) +from scrapebadger.youtube.models import ( + LiveStreamingDetails as YoutubeLiveStreamingDetails, +) +from scrapebadger.youtube.models import ( + MarketInfo as YoutubeMarketInfo, +) +from scrapebadger.youtube.models import ( + MarketsResponse as YoutubeMarketsResponse, +) +from scrapebadger.youtube.models import ( + OEmbed as YoutubeOEmbed, +) +from scrapebadger.youtube.models import ( + Playlist as YoutubePlaylist, +) +from scrapebadger.youtube.models import ( + PlaylistItem as YoutubePlaylistItem, +) +from scrapebadger.youtube.models import ( + PlaylistItemsResponse as YoutubePlaylistItemsResponse, +) +from scrapebadger.youtube.models import ( + PollChoice as YoutubePollChoice, +) +from scrapebadger.youtube.models import ( + ReferenceRow as YoutubeReferenceRow, +) +from scrapebadger.youtube.models import ( + RegionRestriction as YoutubeRegionRestriction, +) +from scrapebadger.youtube.models import ( + RegionsResponse as YoutubeRegionsResponse, +) +from scrapebadger.youtube.models import ( + RelatedResponse as YoutubeRelatedResponse, +) +from scrapebadger.youtube.models import ( + RepliesResponse as YoutubeRepliesResponse, +) +from scrapebadger.youtube.models import ( + ResolveResult as YoutubeResolveResult, +) +from scrapebadger.youtube.models import ( + SearchChip as YoutubeSearchChip, +) +from scrapebadger.youtube.models import ( + SearchResponse as YoutubeSearchResponse, +) +from scrapebadger.youtube.models import ( + SearchResult as YoutubeSearchResult, +) +from scrapebadger.youtube.models import ( + ShoppingResult as YoutubeShoppingResult, +) +from scrapebadger.youtube.models import ( + Short as YoutubeShort, +) +from scrapebadger.youtube.models import ( + StreamingData as YoutubeStreamingData, +) +from scrapebadger.youtube.models import ( + SubscriberCount as YoutubeSubscriberCount, +) +from scrapebadger.youtube.models import ( + Thumbnail as YoutubeThumbnail, +) +from scrapebadger.youtube.models import ( + Transcript as YoutubeTranscript, +) +from scrapebadger.youtube.models import ( + TranscriptSegment as YoutubeTranscriptSegment, +) +from scrapebadger.youtube.models import ( + TrendingItem as YoutubeTrendingItem, +) +from scrapebadger.youtube.models import ( + TrendingResponse as YoutubeTrendingResponse, +) +from scrapebadger.youtube.models import ( + Video as YoutubeVideo, +) -__version__ = "0.15.0" +__version__ = "0.15.1" __all__ = [ # TikTok core models @@ -439,6 +590,57 @@ async def main(): "VintedUserSummary", # Stream exceptions "WebSocketStreamError", + "YoutubeAudioTrack", + "YoutubeAutocompleteResponse", + "YoutubeBatchResponse", + "YoutubeCaptionTrack", + "YoutubeCaptionsResponse", + "YoutubeCategoriesResponse", + "YoutubeChannel", + "YoutubeChannelAbout", + "YoutubeChannelLink", + "YoutubeChannelTabResponse", + "YoutubeChannelVideosResponse", + "YoutubeChapter", + "YoutubeClient", + "YoutubeComment", + "YoutubeCommentsResponse", + "YoutubeCommunityPost", + "YoutubeCommunityResponse", + "YoutubeFormat", + "YoutubeHashtagResponse", + "YoutubeHeatMarker", + "YoutubeHomeResponse", + "YoutubeLanguagesResponse", + "YoutubeLiveChatMessage", + "YoutubeLiveChatResponse", + "YoutubeLiveStreamingDetails", + "YoutubeMarketInfo", + "YoutubeMarketsResponse", + "YoutubeOEmbed", + "YoutubePlaylist", + "YoutubePlaylistItem", + "YoutubePlaylistItemsResponse", + "YoutubePollChoice", + "YoutubeReferenceRow", + "YoutubeRegionRestriction", + "YoutubeRegionsResponse", + "YoutubeRelatedResponse", + "YoutubeRepliesResponse", + "YoutubeResolveResult", + "YoutubeSearchChip", + "YoutubeSearchResponse", + "YoutubeSearchResult", + "YoutubeShoppingResult", + "YoutubeShort", + "YoutubeStreamingData", + "YoutubeSubscriberCount", + "YoutubeThumbnail", + "YoutubeTranscript", + "YoutubeTranscriptSegment", + "YoutubeTrendingItem", + "YoutubeTrendingResponse", + "YoutubeVideo", # Version "__version__", ] diff --git a/src/scrapebadger/_internal/client.py b/src/scrapebadger/_internal/client.py index 8e809e5..bc82194 100644 --- a/src/scrapebadger/_internal/client.py +++ b/src/scrapebadger/_internal/client.py @@ -29,7 +29,7 @@ T = TypeVar("T") # User agent for SDK requests -SDK_VERSION = "0.15.0" +SDK_VERSION = "0.15.1" USER_AGENT = f"scrapebadger-python/{SDK_VERSION}" diff --git a/src/scrapebadger/client.py b/src/scrapebadger/client.py index 6362eb2..cb50f8a 100644 --- a/src/scrapebadger/client.py +++ b/src/scrapebadger/client.py @@ -17,6 +17,7 @@ from scrapebadger.twitter.client import TwitterClient from scrapebadger.vinted.client import VintedClient from scrapebadger.web.client import WebClient +from scrapebadger.youtube.client import YoutubeClient if TYPE_CHECKING: from types import TracebackType @@ -121,6 +122,7 @@ def __init__( self._reddit: RedditClient | None = None self._amazon: AmazonClient | None = None self._ebay: EbayClient | None = None + self._youtube: YoutubeClient | None = None self._tiktok: TikTokClient | None = None @property @@ -282,6 +284,26 @@ def ebay(self) -> EbayClient: self._ebay = EbayClient(self._base_client) return self._ebay + @property + def youtube(self) -> YoutubeClient: + """Access YouTube Scraper API operations. + + Returns: + YoutubeClient providing access to all YouTube endpoints (search, + videos, channels, playlists, comments, transcripts, trending, + shorts, community, music, reference). + + Example: + ```python + results = await client.youtube.search.search("lofi hip hop") + video = await client.youtube.videos.get_video("dQw4w9WgXcQ") + channel = await client.youtube.channels.get_channel("@mkbhd") + ``` + """ + if self._youtube is None: + self._youtube = YoutubeClient(self._base_client) + return self._youtube + @property def tiktok(self) -> TikTokClient: """Access TikTok Scraper API operations. diff --git a/src/scrapebadger/youtube/__init__.py b/src/scrapebadger/youtube/__init__.py new file mode 100644 index 0000000..081665a --- /dev/null +++ b/src/scrapebadger/youtube/__init__.py @@ -0,0 +1,145 @@ +"""YouTube API module for ScrapeBadger SDK. + +This module provides a comprehensive async client for scraping YouTube data +through the ScrapeBadger API. All methods are async and return strongly-typed +Pydantic models. List endpoints paginate via an opaque ``continuation`` token +(no page numbers); ``gl`` selects the content region and ``hl`` the UI language. + +Example: + ```python + from scrapebadger import ScrapeBadger + + async with ScrapeBadger(api_key="your-key") as client: + # Search for videos + results = await client.youtube.search.search("lofi hip hop") + for r in results.results: + print(f"{r.position}. {r.title}") + + # Get video detail + video = await client.youtube.videos.get_video("dQw4w9WgXcQ") + print(video.title) + + # Get a channel + channel = await client.youtube.channels.get_channel("@mkbhd") + print(f"{channel.title}: {channel.number_of_subscribers:,} subscribers") + ``` +""" + +from scrapebadger.youtube.client import YoutubeClient +from scrapebadger.youtube.models import ( + AudioTrack, + AutocompleteResponse, + BatchResponse, + CaptionsResponse, + CaptionTrack, + CategoriesResponse, + Channel, + ChannelAbout, + ChannelLink, + ChannelTabResponse, + ChannelVideosResponse, + Chapter, + Comment, + CommentsResponse, + CommunityPost, + CommunityResponse, + Format, + HashtagResponse, + HeatMarker, + HomeResponse, + LanguagesResponse, + LiveChatMessage, + LiveChatResponse, + LiveStreamingDetails, + MarketInfo, + MarketsResponse, + OEmbed, + Playlist, + PlaylistItem, + PlaylistItemsResponse, + PollChoice, + ReferenceRow, + RegionRestriction, + RegionsResponse, + RelatedResponse, + RepliesResponse, + ResolveResult, + SearchChip, + SearchResponse, + SearchResult, + ShoppingResult, + Short, + StreamingData, + SubscriberCount, + Thumbnail, + Transcript, + TranscriptSegment, + TrendingItem, + TrendingResponse, + Video, +) + +__all__ = [ + # Video / streaming + "AudioTrack", + # Search + "AutocompleteResponse", + "BatchResponse", + # Transcript + "CaptionTrack", + "CaptionsResponse", + # Reference + "CategoriesResponse", + # Channel + "Channel", + "ChannelAbout", + "ChannelLink", + "ChannelTabResponse", + "ChannelVideosResponse", + "Chapter", + # Comments + "Comment", + "CommentsResponse", + # Community + "CommunityPost", + "CommunityResponse", + "Format", + "HashtagResponse", + "HeatMarker", + "HomeResponse", + "LanguagesResponse", + "LiveChatMessage", + "LiveChatResponse", + "LiveStreamingDetails", + "MarketInfo", + "MarketsResponse", + "OEmbed", + # Playlist + "Playlist", + "PlaylistItem", + "PlaylistItemsResponse", + "PollChoice", + "ReferenceRow", + "RegionRestriction", + "RegionsResponse", + "RelatedResponse", + "RepliesResponse", + "ResolveResult", + "SearchChip", + "SearchResponse", + "SearchResult", + "ShoppingResult", + "Short", + "StreamingData", + "SubscriberCount", + # Shared + "Thumbnail", + "Transcript", + "TranscriptSegment", + # Trending + "TrendingItem", + "TrendingResponse", + "Video", + # Client + "YoutubeClient", +] diff --git a/src/scrapebadger/youtube/channels.py b/src/scrapebadger/youtube/channels.py new file mode 100644 index 0000000..78d5588 --- /dev/null +++ b/src/scrapebadger/youtube/channels.py @@ -0,0 +1,378 @@ +"""YouTube Channels API client. + +Provides methods for channel detail, handle/URL resolution, the channel tabs +(videos, shorts, streams, playlists, community), about, subscriber count, and +in-channel search. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from scrapebadger.youtube.models import ( + Channel, + ChannelAbout, + ChannelTabResponse, + CommunityPost, + CommunityResponse, + ResolveResult, + SearchResponse, + SubscriberCount, +) + +if TYPE_CHECKING: + from scrapebadger._internal.client import BaseClient + + +class ChannelsClient: + """Client for YouTube channel endpoints (detail, tabs, about, search, resolve). + + Example: + ```python + async with ScrapeBadger(api_key="key") as client: + channel = await client.youtube.channels.get_channel("@mkbhd") + print(channel.title, channel.number_of_subscribers) + + videos = await client.youtube.channels.get_videos("@mkbhd") + for v in videos.items: + print(v.title) + ``` + """ + + def __init__(self, client: BaseClient) -> None: + """Initialize channels client. + + Args: + client: The base HTTP client. + """ + self._client = client + + async def get_channel( + self, + channel_id: str, + *, + gl: str | None = None, + hl: str | None = None, + ) -> Channel: + """Get a channel's full detail. + + Args: + channel_id: A UC id, @handle, or custom URL. + gl: Content region (US, GB, DE…). + hl: UI language. + + Returns: + Full channel detail including subscribers, banners, links, and tabs. + + Raises: + NotFoundError: If the channel doesn't exist. + AuthenticationError: If the API key is invalid. + + Example: + ```python + channel = await client.youtube.channels.get_channel("@mkbhd") + ``` + """ + params: dict[str, Any] = {"gl": gl, "hl": hl} + response = await self._client.get(f"/v1/youtube/channels/{channel_id}", params=params) + return Channel.model_validate(response) + + async def resolve( + self, + *, + handle: str | None = None, + url: str | None = None, + gl: str | None = None, + hl: str | None = None, + ) -> ResolveResult: + """Resolve a handle or URL to canonical channel/video/playlist ids. + + Args: + handle: A @handle or custom name. + url: A full channel/video/playlist URL. + gl: Content region (US, GB, DE…). + hl: UI language. + + Returns: + Resolve result with the canonical ids and URL. + + Example: + ```python + result = await client.youtube.channels.resolve(handle="@mkbhd") + print(result.channel_id) + ``` + """ + params: dict[str, Any] = {"handle": handle, "url": url, "gl": gl, "hl": hl} + response = await self._client.get("/v1/youtube/channels/resolve", params=params) + return ResolveResult.model_validate(response) + + async def get_videos( + self, + channel_id: str, + *, + continuation: str | None = None, + gl: str | None = None, + hl: str | None = None, + ) -> ChannelTabResponse: + """List a channel's videos. + + Args: + channel_id: A UC id, @handle, or custom URL. + continuation: Pagination token from a previous page. + gl: Content region (US, GB, DE…). + hl: UI language. + + Returns: + A tab response with video items and a continuation token. + + Example: + ```python + videos = await client.youtube.channels.get_videos("@mkbhd") + ``` + """ + params: dict[str, Any] = {"continuation": continuation, "gl": gl, "hl": hl} + response = await self._client.get( + f"/v1/youtube/channels/{channel_id}/videos", params=params + ) + return ChannelTabResponse.model_validate(response) + + async def get_shorts( + self, + channel_id: str, + *, + continuation: str | None = None, + gl: str | None = None, + hl: str | None = None, + ) -> ChannelTabResponse: + """List a channel's Shorts. + + Args: + channel_id: A UC id, @handle, or custom URL. + continuation: Pagination token from a previous page. + gl: Content region (US, GB, DE…). + hl: UI language. + + Returns: + A tab response with Shorts items and a continuation token. + + Example: + ```python + shorts = await client.youtube.channels.get_shorts("@mkbhd") + ``` + """ + params: dict[str, Any] = {"continuation": continuation, "gl": gl, "hl": hl} + response = await self._client.get( + f"/v1/youtube/channels/{channel_id}/shorts", params=params + ) + return ChannelTabResponse.model_validate(response) + + async def get_streams( + self, + channel_id: str, + *, + continuation: str | None = None, + gl: str | None = None, + hl: str | None = None, + ) -> ChannelTabResponse: + """List a channel's live streams. + + Args: + channel_id: A UC id, @handle, or custom URL. + continuation: Pagination token from a previous page. + gl: Content region (US, GB, DE…). + hl: UI language. + + Returns: + A tab response with stream items and a continuation token. + + Example: + ```python + streams = await client.youtube.channels.get_streams("@mkbhd") + ``` + """ + params: dict[str, Any] = {"continuation": continuation, "gl": gl, "hl": hl} + response = await self._client.get( + f"/v1/youtube/channels/{channel_id}/streams", params=params + ) + return ChannelTabResponse.model_validate(response) + + async def get_playlists( + self, + channel_id: str, + *, + continuation: str | None = None, + gl: str | None = None, + hl: str | None = None, + ) -> ChannelTabResponse: + """List a channel's playlists. + + Args: + channel_id: A UC id, @handle, or custom URL. + continuation: Pagination token from a previous page. + gl: Content region (US, GB, DE…). + hl: UI language. + + Returns: + A tab response with playlist items and a continuation token. + + Example: + ```python + playlists = await client.youtube.channels.get_playlists("@mkbhd") + ``` + """ + params: dict[str, Any] = {"continuation": continuation, "gl": gl, "hl": hl} + response = await self._client.get( + f"/v1/youtube/channels/{channel_id}/playlists", params=params + ) + return ChannelTabResponse.model_validate(response) + + async def get_community( + self, + channel_id: str, + *, + continuation: str | None = None, + gl: str | None = None, + hl: str | None = None, + ) -> CommunityResponse: + """List a channel's community posts. + + Args: + channel_id: A UC id, @handle, or custom URL. + continuation: Pagination token from a previous page. + gl: Content region (US, GB, DE…). + hl: UI language. + + Returns: + Community response with posts and a continuation token. + + Example: + ```python + community = await client.youtube.channels.get_community("@mkbhd") + ``` + """ + params: dict[str, Any] = {"continuation": continuation, "gl": gl, "hl": hl} + response = await self._client.get( + f"/v1/youtube/channels/{channel_id}/community", params=params + ) + return CommunityResponse.model_validate(response) + + async def get_about( + self, + channel_id: str, + *, + gl: str | None = None, + hl: str | None = None, + ) -> ChannelAbout: + """Get a channel's lightweight about payload. + + Args: + channel_id: A UC id, @handle, or custom URL. + gl: Content region (US, GB, DE…). + hl: UI language. + + Returns: + Channel about with description, links, totals, and join date. + + Example: + ```python + about = await client.youtube.channels.get_about("@mkbhd") + ``` + """ + params: dict[str, Any] = {"gl": gl, "hl": hl} + response = await self._client.get(f"/v1/youtube/channels/{channel_id}/about", params=params) + return ChannelAbout.model_validate(response) + + async def get_subscriber_count( + self, + channel_id: str, + *, + gl: str | None = None, + hl: str | None = None, + ) -> SubscriberCount: + """Get a channel's subscriber count (fast path). + + Args: + channel_id: A UC id, @handle, or custom URL. + gl: Content region (US, GB, DE…). + hl: UI language. + + Returns: + Subscriber count response. + + Example: + ```python + subs = await client.youtube.channels.get_subscriber_count("@mkbhd") + print(subs.number_of_subscribers) + ``` + """ + params: dict[str, Any] = {"gl": gl, "hl": hl} + response = await self._client.get( + f"/v1/youtube/channels/{channel_id}/subscriber_count", params=params + ) + return SubscriberCount.model_validate(response) + + async def search( + self, + channel_id: str, + query: str, + *, + continuation: str | None = None, + gl: str | None = None, + hl: str | None = None, + ) -> SearchResponse: + """Search within a single channel. + + Args: + channel_id: A UC id, @handle, or custom URL. + query: Search keywords. + continuation: Pagination token from a previous page. + gl: Content region (US, GB, DE…). + hl: UI language. + + Returns: + Search response with matching results and a continuation token. + + Example: + ```python + results = await client.youtube.channels.search("@mkbhd", "iphone") + ``` + """ + params: dict[str, Any] = { + "query": query, + "continuation": continuation, + "gl": gl, + "hl": hl, + } + response = await self._client.get( + f"/v1/youtube/channels/{channel_id}/search", params=params + ) + return SearchResponse.model_validate(response) + + async def get_post( + self, + post_id: str, + *, + gl: str | None = None, + hl: str | None = None, + ) -> CommunityPost: + """Get a single community post's detail. + + Args: + post_id: The community post id. + gl: Content region (US, GB, DE…). + hl: UI language. + + Returns: + Community post detail (text, poll, images, or attached video/shared post). + + Raises: + NotFoundError: If the post doesn't exist. + + Example: + ```python + post = await client.youtube.channels.get_post("postId") + ``` + """ + params: dict[str, Any] = {"gl": gl, "hl": hl} + response = await self._client.get(f"/v1/youtube/posts/{post_id}", params=params) + return CommunityPost.model_validate(response) diff --git a/src/scrapebadger/youtube/client.py b/src/scrapebadger/youtube/client.py new file mode 100644 index 0000000..5fd29f7 --- /dev/null +++ b/src/scrapebadger/youtube/client.py @@ -0,0 +1,211 @@ +"""YouTube API client combining all sub-clients. + +This module provides the main YoutubeClient class that serves as the +entry point for all YouTube API operations. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from scrapebadger.youtube.channels import ChannelsClient +from scrapebadger.youtube.comments import CommentsClient +from scrapebadger.youtube.playlists import PlaylistsClient +from scrapebadger.youtube.reference import ReferenceClient +from scrapebadger.youtube.search import SearchClient +from scrapebadger.youtube.transcript import TranscriptClient +from scrapebadger.youtube.trending import TrendingClient +from scrapebadger.youtube.videos import VideosClient + +if TYPE_CHECKING: + from scrapebadger._internal.client import BaseClient + + +class YoutubeClient: + """Client for all YouTube API operations. + + This class provides access to all YouTube scraping endpoints through + organized sub-clients for different resource types. All YouTube list + endpoints paginate via an opaque ``continuation`` token (no page numbers); + ``gl`` selects the content region and ``hl`` the UI language. + + Attributes: + search: Client for search, music search, autocomplete, hashtag, and home. + videos: Client for video detail, batch, related, streams, live chat, + oEmbed, and Shorts. + channels: Client for channel detail, tabs, about, search, resolve, and posts. + playlists: Client for playlist detail, items, and mixes. + trending: Client for trending videos and trending Shorts. + comments: Client for video comments, replies, and community post comments. + transcript: Client for video transcripts and caption tracks. + reference: Client for reference data (categories, languages, regions, markets). + + Example: + ```python + from scrapebadger import ScrapeBadger + + async with ScrapeBadger(api_key="your-key") as client: + # Search for videos + results = await client.youtube.search.search("lofi hip hop") + for r in results.results: + print(f"{r.position}. {r.title}") + + # Get video detail + video = await client.youtube.videos.get_video("dQw4w9WgXcQ") + print(video.title) + + # Get a channel + channel = await client.youtube.channels.get_channel("@mkbhd") + + # Get supported markets + markets = await client.youtube.reference.list_markets() + ``` + + Note: + This client is not instantiated directly. Instead, access it through + the `youtube` property of the main `ScrapeBadger` client. + """ + + def __init__(self, client: BaseClient) -> None: + """Initialize YouTube client with all sub-clients. + + Args: + client: The base HTTP client for making API requests. + """ + self._client = client + + # Initialize sub-clients + self._search = SearchClient(client) + self._videos = VideosClient(client) + self._channels = ChannelsClient(client) + self._playlists = PlaylistsClient(client) + self._trending = TrendingClient(client) + self._comments = CommentsClient(client) + self._transcript = TranscriptClient(client) + self._reference = ReferenceClient(client) + + @property + def search(self) -> SearchClient: + """Access search, music search, autocomplete, hashtag, and home endpoints. + + Returns: + SearchClient for keyword search, YouTube Music, autocomplete, hashtags, and home. + + Example: + ```python + results = await client.youtube.search.search("python tutorial") + songs = await client.youtube.search.music("daft punk") + suggestions = await client.youtube.search.autocomplete("lofi") + ``` + """ + return self._search + + @property + def videos(self) -> VideosClient: + """Access video detail, batch, related, streams, live chat, oEmbed, and Shorts. + + Returns: + VideosClient for video and Shorts endpoints. + + Example: + ```python + video = await client.youtube.videos.get_video("dQw4w9WgXcQ") + related = await client.youtube.videos.get_related("dQw4w9WgXcQ") + batch = await client.youtube.videos.batch(["id1", "id2"]) + ``` + """ + return self._videos + + @property + def channels(self) -> ChannelsClient: + """Access channel detail, tabs, about, search, resolve, and post endpoints. + + Returns: + ChannelsClient for channel endpoints. + + Example: + ```python + channel = await client.youtube.channels.get_channel("@mkbhd") + videos = await client.youtube.channels.get_videos("@mkbhd") + resolved = await client.youtube.channels.resolve(handle="@mkbhd") + ``` + """ + return self._channels + + @property + def playlists(self) -> PlaylistsClient: + """Access playlist detail, items, and mix endpoints. + + Returns: + PlaylistsClient for playlist and mix endpoints. + + Example: + ```python + playlist = await client.youtube.playlists.get_playlist("PLxxxx") + page = await client.youtube.playlists.get_items("PLxxxx") + mix = await client.youtube.playlists.get_mix("RDxxxx") + ``` + """ + return self._playlists + + @property + def trending(self) -> TrendingClient: + """Access trending videos and trending Shorts endpoints. + + Returns: + TrendingClient for trending feeds. + + Example: + ```python + trending = await client.youtube.trending.trending(type="music") + shorts = await client.youtube.trending.shorts(gl="US") + ``` + """ + return self._trending + + @property + def comments(self) -> CommentsClient: + """Access video comments, replies, and community post comment endpoints. + + Returns: + CommentsClient for comment endpoints. + + Example: + ```python + comments = await client.youtube.comments.get_comments("dQw4w9WgXcQ") + replies = await client.youtube.comments.get_replies( + "dQw4w9WgXcQ", "commentId", continuation="token" + ) + ``` + """ + return self._comments + + @property + def transcript(self) -> TranscriptClient: + """Access transcript and caption-track endpoints. + + Returns: + TranscriptClient for transcript and caption endpoints. + + Example: + ```python + transcript = await client.youtube.transcript.get_transcript("dQw4w9WgXcQ") + captions = await client.youtube.transcript.get_captions("dQw4w9WgXcQ") + ``` + """ + return self._transcript + + @property + def reference(self) -> ReferenceClient: + """Access reference data endpoints. + + Returns: + ReferenceClient for categories, languages, regions, and markets. + + Example: + ```python + categories = await client.youtube.reference.list_categories() + markets = await client.youtube.reference.list_markets() + ``` + """ + return self._reference diff --git a/src/scrapebadger/youtube/comments.py b/src/scrapebadger/youtube/comments.py new file mode 100644 index 0000000..340df89 --- /dev/null +++ b/src/scrapebadger/youtube/comments.py @@ -0,0 +1,141 @@ +"""YouTube Comments API client. + +Provides methods for video comments, comment replies, and community post comments. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from scrapebadger.youtube.models import CommentsResponse, RepliesResponse + +if TYPE_CHECKING: + from scrapebadger._internal.client import BaseClient + + +class CommentsClient: + """Client for YouTube comment endpoints (video comments, replies, post comments). + + Example: + ```python + async with ScrapeBadger(api_key="key") as client: + comments = await client.youtube.comments.get_comments("dQw4w9WgXcQ") + for c in comments.comments: + print(f"{c.author}: {c.text}") + + if comments.comments and comments.comments[0].replies_continuation: + replies = await client.youtube.comments.get_replies( + "dQw4w9WgXcQ", + comments.comments[0].comment_id, + continuation=comments.comments[0].replies_continuation, + ) + ``` + """ + + def __init__(self, client: BaseClient) -> None: + """Initialize comments client. + + Args: + client: The base HTTP client. + """ + self._client = client + + async def get_comments( + self, + video_id: str, + *, + sort_by: str = "top", + continuation: str | None = None, + gl: str | None = None, + hl: str | None = None, + ) -> CommentsResponse: + """Get a page of top-level comments for a video. + + Args: + video_id: The YouTube video id. + sort_by: Comment sort order ("top", "newest"). Defaults to "top". + continuation: Pagination token from a previous page. + gl: Content region (US, GB, DE…). + hl: UI language. + + Returns: + Comments response with comments, sorting tokens, and a continuation token. + + Example: + ```python + comments = await client.youtube.comments.get_comments( + "dQw4w9WgXcQ", sort_by="newest" + ) + ``` + """ + params: dict[str, Any] = { + "sort_by": sort_by, + "continuation": continuation, + "gl": gl, + "hl": hl, + } + response = await self._client.get(f"/v1/youtube/videos/{video_id}/comments", params=params) + return CommentsResponse.model_validate(response) + + async def get_replies( + self, + video_id: str, + comment_id: str, + *, + continuation: str, + gl: str | None = None, + hl: str | None = None, + ) -> RepliesResponse: + """Get a page of replies to a comment. + + Args: + video_id: The YouTube video id. + comment_id: The parent comment id. + continuation: Replies continuation token (from a comment's + ``replies_continuation``). + gl: Content region (US, GB, DE…). + hl: UI language. + + Returns: + Replies response with reply comments and a continuation token. + + Example: + ```python + replies = await client.youtube.comments.get_replies( + "dQw4w9WgXcQ", "commentId", continuation="token" + ) + ``` + """ + params: dict[str, Any] = {"continuation": continuation, "gl": gl, "hl": hl} + response = await self._client.get( + f"/v1/youtube/videos/{video_id}/comments/{comment_id}/replies", params=params + ) + return RepliesResponse.model_validate(response) + + async def get_post_comments( + self, + post_id: str, + *, + continuation: str | None = None, + gl: str | None = None, + hl: str | None = None, + ) -> CommentsResponse: + """Get a page of comments on a community post. + + Args: + post_id: The community post id. + continuation: Pagination token from a previous page. + gl: Content region (US, GB, DE…). + hl: UI language. + + Returns: + Comments response with comments and a continuation token. + + Example: + ```python + comments = await client.youtube.comments.get_post_comments("postId") + ``` + """ + params: dict[str, Any] = {"continuation": continuation, "gl": gl, "hl": hl} + response = await self._client.get(f"/v1/youtube/posts/{post_id}/comments", params=params) + return CommentsResponse.model_validate(response) diff --git a/src/scrapebadger/youtube/models.py b/src/scrapebadger/youtube/models.py new file mode 100644 index 0000000..e2f54d4 --- /dev/null +++ b/src/scrapebadger/youtube/models.py @@ -0,0 +1,820 @@ +"""Pydantic models for YouTube API responses. + +These models mirror the backend ``youtube_scraper`` response schema field-for-field. +All models are immutable (frozen) and ignore unknown fields for forward +compatibility. YouTube list endpoints paginate via an opaque ``continuation`` +token (no page numbers); ``gl`` selects the content region and ``hl`` the UI +language. Every datetime field ships in BOTH ``*_utc`` (Unix float) and ``*_at`` +(ISO-8601 Z string). +""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +# ============================================================================= +# Base Configuration +# ============================================================================= + + +class _BaseModel(BaseModel): + """Base model with common configuration.""" + + model_config = ConfigDict( + frozen=True, + populate_by_name=True, + extra="ignore", + ) + + +# ============================================================================= +# Shared / common models +# ============================================================================= + + +class Thumbnail(_BaseModel): + """A single image variant.""" + + url: str + width: int | None = None + height: int | None = None + + +class ResolveResult(_BaseModel): + """Result of resolving a handle / custom URL to canonical ids.""" + + input: str + type: str | None = None # channel | video | playlist + channel_id: str | None = None + channel_username: str | None = None + video_id: str | None = None + playlist_id: str | None = None + canonical_url: str | None = None + + +class ReferenceRow(_BaseModel): + """A reference lookup row (category / language / region).""" + + id: str + title: str + + +class OEmbed(_BaseModel): + """YouTube public oEmbed response (https://www.youtube.com/oembed).""" + + type: str | None = None + version: str | None = None + title: str | None = None + author_name: str | None = None + author_url: str | None = None + provider_name: str | None = None + provider_url: str | None = None + thumbnail_url: str | None = None + thumbnail_width: int | None = None + thumbnail_height: int | None = None + width: int | None = None + height: int | None = None + html: str | None = None + + +# ============================================================================= +# Video / Short / streaming +# ============================================================================= + + +class Chapter(_BaseModel): + """A video chapter (from ``next`` macroMarkersListRenderer — manual or auto).""" + + title: str | None = None + start_seconds: int | None = None + start_time_text: str | None = None + thumbnail: str | None = None + + +class HeatMarker(_BaseModel): + """A most-replayed graph point (from ``next`` heatMarkers).""" + + start_seconds: float | None = None + duration_seconds: float | None = None + intensity: float | None = None # 0.0-1.0 normalized score + + +class AudioTrack(_BaseModel): + """A multi-language audio track (auto-dubbing / original).""" + + id: str | None = None + display_name: str | None = None + language: str | None = None + is_default: bool | None = None + is_original: bool | None = None + + +class Format(_BaseModel): + """A single streaming format (muxed or adaptive).""" + + itag: int | None = None + mime_type: str | None = None + codecs: str | None = None + bitrate: int | None = None + average_bitrate: int | None = None + width: int | None = None + height: int | None = None + fps: int | None = None + quality: str | None = None + quality_label: str | None = None + audio_quality: str | None = None + audio_sample_rate: int | None = None + audio_channels: int | None = None + is_drc: bool | None = None # stable-volume (dynamic range compression) audio + loudness_db: float | None = None + audio_track: AudioTrack | None = None + content_length: int | None = None + approx_duration_ms: int | None = None + url: str | None = None # may require a PO token (best-effort) + signature_cipher: str | None = None + projection_type: str | None = None + + +class StreamingData(_BaseModel): + """Streaming metadata from ``player.streamingData`` (best-effort URLs).""" + + expires_in_seconds: int | None = None + hls_manifest_url: str | None = None + dash_manifest_url: str | None = None + formats: list[Format] = Field(default_factory=list) + adaptive_formats: list[Format] = Field(default_factory=list) + + +class ShoppingResult(_BaseModel): + """A product shelf entry attached to a video.""" + + title: str | None = None + price: str | None = None + vendor: str | None = None + thumbnail: str | None = None + link: str | None = None + + +class LiveStreamingDetails(_BaseModel): + """Live/premiere timing details.""" + + actual_start_at: str | None = None + actual_start_utc: float | None = None + actual_end_at: str | None = None + actual_end_utc: float | None = None + scheduled_start_at: str | None = None + scheduled_start_utc: float | None = None + scheduled_end_at: str | None = None + scheduled_end_utc: float | None = None + concurrent_viewers: int | None = None + active_live_chat_id: str | None = None + + +class RegionRestriction(_BaseModel): + """Allowed/blocked country lists for a video.""" + + allowed: list[str] = Field(default_factory=list) + blocked: list[str] = Field(default_factory=list) + + +class Video(_BaseModel): + """Full video detail — merged ``player`` + ``next`` (the flagship entity).""" + + # Core identity + video_id: str + title: str | None = None + url: str | None = None + description: str | None = None + description_links: list[dict] = Field(default_factory=list) + length_seconds: int | None = None + duration: str | None = None # HH:MM:SS + + # Engagement + view_count: int | None = None + view_count_text: str | None = None + like_count: int | None = None + like_count_text: str | None = None + comment_count: int | None = None + dislike_count: int | None = None # private platform-wide since 2021 (null) + + # Channel + channel_id: str | None = None + channel_name: str | None = None + channel_username: str | None = None + channel_url: str | None = None + channel_thumbnail: str | None = None + channel_is_verified: bool | None = None + is_official_artist: bool | None = None + number_of_subscribers: int | None = None + subscriber_count_text: str | None = None + + # Dates + published_at: str | None = None + published_utc: float | None = None + upload_at: str | None = None + upload_utc: float | None = None + published_time_text: str | None = None + + # Classification + keywords: list[str] = Field(default_factory=list) + tags: list[str] = Field(default_factory=list) + hashtags: list[str] = Field(default_factory=list) + category: str | None = None + category_id: str | None = None + topic_categories: list[str] = Field(default_factory=list) + + # Media + thumbnails: list[Thumbnail] = Field(default_factory=list) + thumbnail: str | None = None + storyboards: dict | None = None + chapters: list[Chapter] = Field(default_factory=list) + heatmap: list[HeatMarker] = Field(default_factory=list) + + # Live / premiere + is_live_content: bool | None = None + live_now: bool | None = None + is_upcoming: bool | None = None + is_post_live_dvr: bool | None = None + premiere_utc: float | None = None + premiere_at: str | None = None + live_streaming_details: LiveStreamingDetails | None = None + + # Status / safety + is_family_safe: bool | None = None + is_age_restricted: bool | None = None + is_members_only: bool | None = None + is_unlisted: bool | None = None + is_private: bool | None = None + is_crawlable: bool | None = None + is_shorts_eligible: bool | None = None + is_short: bool | None = None + allow_ratings: bool | None = None + is_monetized: bool | None = None + comments_turned_off: bool | None = None + has_paid_product_placement: bool | None = None + available_countries: list[str] = Field(default_factory=list) + region_restriction: RegionRestriction | None = None + playability_status: str | None = None + playability_reason: str | None = None + + # Technical + definition: str | None = None # hd / sd + dimension: str | None = None # 2d / 3d + projection: str | None = None # rectangular / 360 + captions_available: bool | None = None + caption_languages: list[str] = Field(default_factory=list) + + # Rich shelves (from next) + shopping_results: list[ShoppingResult] = Field(default_factory=list) + additional_info: list[dict] = Field(default_factory=list) + game_info: dict | None = None + end_screen_videos: list[dict] = Field(default_factory=list) + related_videos: list[dict] = Field(default_factory=list) + related_videos_continuation: str | None = None + + # Sound (shorts) + sound_id: str | None = None + sound_title: str | None = None + sound_artist: str | None = None + + # Streams (only when requested) + streams: StreamingData | None = None + embed_url: str | None = None + + scraped_at: str | None = None + scraped_utc: float | None = None + + +class Short(Video): + """A Shorts (vertical reel) — Video subtype with ``is_short=True``.""" + + thumbnail_vertical: str | None = None + + +# ============================================================================= +# Search / autocomplete / feed +# ============================================================================= + + +class SearchResult(_BaseModel): + """A polymorphic search/feed result (keyed by ``type``).""" + + type: str # video | channel | playlist | movie | short + position: int | None = None + + # video / short / movie + video_id: str | None = None + title: str | None = None + url: str | None = None + thumbnails: list[Thumbnail] = Field(default_factory=list) + thumbnail: str | None = None + duration: str | None = None + length_seconds: int | None = None + view_count: int | None = None + view_count_text: str | None = None + short_view_count_text: str | None = None + published_time_text: str | None = None + published_at: str | None = None + published_utc: float | None = None + description_snippet: str | None = None + badges: list[str] = Field(default_factory=list) + extensions: list[str] = Field(default_factory=list) + is_live: bool | None = None + is_short: bool | None = None + is_members_only: bool | None = None + + # channel association (for video/playlist results) + channel_id: str | None = None + channel_name: str | None = None + channel_url: str | None = None + channel_thumbnail: str | None = None + channel_is_verified: bool | None = None + + # channel result + subscriber_count_text: str | None = None + number_of_subscribers: int | None = None + video_count_text: str | None = None + channel_username: str | None = None + is_verified: bool | None = None + + # playlist result + playlist_id: str | None = None + video_count: int | None = None + first_video_id: str | None = None + + +class SearchChip(_BaseModel): + """A search filter chip / refinement.""" + + title: str | None = None + is_selected: bool | None = None + continuation: str | None = None + + +class SearchResponse(_BaseModel): + """A page of search results (search, music search, channel search).""" + + query: str | None = None + results: list[SearchResult] = Field(default_factory=list) + total_results: int | None = None + estimated_results: int | None = None + chips: list[SearchChip] = Field(default_factory=list) + did_you_mean: str | None = None + showing_results_for: str | None = None + refinements: list[str] = Field(default_factory=list) + related_searches: list[dict] = Field(default_factory=list) + continuation: str | None = None + + +class AutocompleteResponse(_BaseModel): + """Keyword suggestions for a query prefix.""" + + query: str + suggestions: list[str] = Field(default_factory=list) + + +class RelatedResponse(_BaseModel): + """Related videos for a video (secondaryResults).""" + + video_id: str | None = None + results: list[SearchResult] = Field(default_factory=list) + continuation: str | None = None + + +class HashtagResponse(_BaseModel): + """Videos under a hashtag.""" + + tag: str + results: list[SearchResult] = Field(default_factory=list) + continuation: str | None = None + + +class HomeResponse(_BaseModel): + """Guest home / recommendations feed (non-deterministic, best-effort).""" + + results: list[SearchResult] = Field(default_factory=list) + continuation: str | None = None + + +class ChannelVideosResponse(_BaseModel): + """A page of a channel's videos / shorts / streams.""" + + channel_id: str | None = None + videos: list[SearchResult] = Field(default_factory=list) + continuation: str | None = None + + +class ChannelTabResponse(_BaseModel): + """A generic page of a channel tab (videos/shorts/streams/playlists).""" + + channel_id: str | None = None + tab: str | None = None + items: list[SearchResult] = Field(default_factory=list) + continuation: str | None = None + + +# ============================================================================= +# Channel +# ============================================================================= + + +class ChannelLink(_BaseModel): + """An external link from a channel's about page / header.""" + + title: str | None = None + url: str | None = None + + +class Channel(_BaseModel): + """Full channel detail (browse About tab + header).""" + + channel_id: str + title: str | None = None + channel_name: str | None = None + channel_username: str | None = None # @handle + custom_url: str | None = None + channel_url: str | None = None + description: str | None = None + description_links: list[ChannelLink] = Field(default_factory=list) + + number_of_subscribers: int | None = None + subscriber_count_text: str | None = None + hidden_subscriber_count: bool | None = None + channel_total_videos: int | None = None + channel_total_videos_text: str | None = None + channel_total_views: int | None = None + channel_total_views_text: str | None = None + + joined_at: str | None = None + joined_utc: float | None = None + country: str | None = None + channel_location: str | None = None + + keywords: list[str] = Field(default_factory=list) + tags: list[str] = Field(default_factory=list) + is_verified: bool | None = None + is_age_restricted: bool | None = None + is_family_safe: bool | None = None + is_monetized: bool | None = None + is_auto_generated: bool | None = None + made_for_kids: bool | None = None + + avatar: str | None = None + banner: str | None = None + tv_banner: str | None = None + mobile_banner: str | None = None + avatar_thumbnails: list[Thumbnail] = Field(default_factory=list) + banner_thumbnails: list[Thumbnail] = Field(default_factory=list) + + external_links: list[ChannelLink] = Field(default_factory=list) + tabs: list[str] = Field(default_factory=list) + available_countries: list[str] = Field(default_factory=list) + + related_channels: list[dict] = Field(default_factory=list) + latest_videos: list[dict] = Field(default_factory=list) + + scraped_at: str | None = None + scraped_utc: float | None = None + + +class ChannelAbout(_BaseModel): + """Lightweight channel about payload.""" + + channel_id: str + description: str | None = None + links: list[ChannelLink] = Field(default_factory=list) + number_of_subscribers: int | None = None + subscriber_count_text: str | None = None + channel_total_views: int | None = None + channel_total_videos: int | None = None + joined_at: str | None = None + joined_utc: float | None = None + country: str | None = None + + +class SubscriberCount(_BaseModel): + """Fast subscriber-count-only response.""" + + channel_id: str + number_of_subscribers: int | None = None + subscriber_count_text: str | None = None + + +# ============================================================================= +# Playlist +# ============================================================================= + + +class PlaylistItem(_BaseModel): + """A single entry in a playlist.""" + + position: int | None = None + video_id: str + title: str | None = None + url: str | None = None + length_seconds: int | None = None + duration: str | None = None + channel_id: str | None = None + channel_name: str | None = None + channel_url: str | None = None + thumbnails: list[Thumbnail] = Field(default_factory=list) + thumbnail: str | None = None + view_count: int | None = None + view_count_text: str | None = None + published_time_text: str | None = None + is_playable: bool | None = None + set_video_id: str | None = None # per-playlist-entry id + + +class Playlist(_BaseModel): + """Full playlist detail + first page of items.""" + + playlist_id: str + title: str | None = None + description: str | None = None + playlist_url: str | None = None + video_count: int | None = None + view_count: int | None = None + view_count_text: str | None = None + last_updated_text: str | None = None + channel_id: str | None = None + channel_name: str | None = None + channel_url: str | None = None + channel_is_verified: bool | None = None + thumbnails: list[Thumbnail] = Field(default_factory=list) + thumbnail: str | None = None + privacy_status: str | None = None + is_mix: bool | None = None + videos: list[PlaylistItem] = Field(default_factory=list) + continuation: str | None = None + scraped_at: str | None = None + scraped_utc: float | None = None + + +class PlaylistItemsResponse(_BaseModel): + """A continuation page of playlist items.""" + + playlist_id: str | None = None + items: list[PlaylistItem] = Field(default_factory=list) + continuation: str | None = None + + +# ============================================================================= +# Comments +# ============================================================================= + + +class Comment(_BaseModel): + """A single comment or reply (from ``commentEntityPayload`` + thread).""" + + comment_id: str + text: str | None = None + published_time_text: str | None = None + published_utc: float | None = None + author: str | None = None + author_channel_id: str | None = None + author_channel_url: str | None = None + author_thumbnail: str | None = None + author_is_verified: bool | None = None + author_is_artist: bool | None = None + author_is_channel_owner: bool | None = None + author_badges: list[str] = Field(default_factory=list) + like_count: int | None = None + reply_count: int | None = None + has_creator_heart: bool | None = None + is_pinned: bool | None = None + is_reply: bool | None = None + reply_to_cid: str | None = None + replies_continuation: str | None = None + paid_comment_amount: str | None = None # Super Thanks purchase amount + video_id: str | None = None + + +class CommentsResponse(_BaseModel): + """A page of top-level comments (video or community post).""" + + video_id: str | None = None + comments: list[Comment] = Field(default_factory=list) + comment_count: int | None = None + sorting_tokens: list[dict] = Field(default_factory=list) + continuation: str | None = None + + +class RepliesResponse(_BaseModel): + """A page of replies to a comment.""" + + comment_id: str | None = None + replies: list[Comment] = Field(default_factory=list) + continuation: str | None = None + + +# ============================================================================= +# Transcript / captions +# ============================================================================= + + +class TranscriptSegment(_BaseModel): + """A single timed transcript line.""" + + start_ms: int | None = None + end_ms: int | None = None + duration_ms: int | None = None + start_time_text: str | None = None + text: str | None = None + + +class Transcript(_BaseModel): + """A full video transcript in the selected language.""" + + video_id: str + language: str | None = None + language_name: str | None = None + type: str | None = None # asr | manual + is_translatable: bool | None = None + available_transcripts: list[dict] = Field(default_factory=list) + translation_languages: list[dict] = Field(default_factory=list) + segments: list[TranscriptSegment] = Field(default_factory=list) + full_text: str | None = None + srt: str | None = None + vtt: str | None = None + + +class CaptionTrack(_BaseModel): + """An available caption track (from player captions tracklist).""" + + language: str | None = None + language_name: str | None = None + type: str | None = None # asr | manual + is_translatable: bool | None = None + base_url: str | None = None + + +class CaptionsResponse(_BaseModel): + """The list of available caption tracks for a video.""" + + video_id: str + tracks: list[CaptionTrack] = Field(default_factory=list) + translation_languages: list[dict] = Field(default_factory=list) + + +# ============================================================================= +# Trending +# ============================================================================= + + +class TrendingItem(_BaseModel): + """A trending video / short.""" + + position: int | None = None + category: str | None = None # Now | Music | Gaming | Movies + video_id: str | None = None + title: str | None = None + url: str | None = None + thumbnails: list[Thumbnail] = Field(default_factory=list) + thumbnail: str | None = None + duration: str | None = None + length_seconds: int | None = None + view_count: int | None = None + view_count_text: str | None = None + published_time_text: str | None = None + description_snippet: str | None = None + channel_id: str | None = None + channel_name: str | None = None + channel_url: str | None = None + channel_thumbnail: str | None = None + channel_is_verified: bool | None = None + badges: list[str] = Field(default_factory=list) + is_live: bool | None = None + is_short: bool | None = None + + +class TrendingResponse(_BaseModel): + """A page of trending items.""" + + gl: str | None = None + type: str | None = None + items: list[TrendingItem] = Field(default_factory=list) + continuation: str | None = None + + +# ============================================================================= +# Community / live chat +# ============================================================================= + + +class PollChoice(_BaseModel): + """A single poll option on a community post.""" + + text: str | None = None + percentage: float | None = None + votes: int | None = None + image: str | None = None + + +class CommunityPost(_BaseModel): + """A channel community / posts-tab entry.""" + + post_id: str + post_url: str | None = None + post_type: str | None = None # text | poll | image | video | shared + text: str | None = None + text_links: list[dict] = Field(default_factory=list) + channel_id: str | None = None + channel_name: str | None = None + channel_url: str | None = None + channel_thumbnail: str | None = None + published_time_text: str | None = None + published_utc: float | None = None + like_count: int | None = None + like_count_text: str | None = None + comment_count: int | None = None + poll_choices: list[PollChoice] = Field(default_factory=list) + poll_total_votes: int | None = None + images: list[Thumbnail] = Field(default_factory=list) + attached_video: dict | None = None + shared_post: dict | None = None + scraped_at: str | None = None + scraped_utc: float | None = None + + +class CommunityResponse(_BaseModel): + """A page of community posts.""" + + channel_id: str | None = None + posts: list[CommunityPost] = Field(default_factory=list) + continuation: str | None = None + + +class LiveChatMessage(_BaseModel): + """A single live-chat / chat-replay message.""" + + message_id: str | None = None + message_type: str | None = None # text | super_chat | super_sticker | membership + text: str | None = None + author: str | None = None + author_channel_id: str | None = None + author_thumbnail: str | None = None + author_badges: list[str] = Field(default_factory=list) + is_moderator: bool | None = None + is_member: bool | None = None + is_verified: bool | None = None + timestamp_usec: int | None = None + timestamp_text: str | None = None + video_offset_time_msec: int | None = None + purchase_amount: str | None = None # super chat / sticker amount + body_background_color: str | None = None + + +class LiveChatResponse(_BaseModel): + """A page of live-chat messages.""" + + video_id: str | None = None + messages: list[LiveChatMessage] = Field(default_factory=list) + continuation: str | None = None + is_replay: bool | None = None + + +# ============================================================================= +# Batch + reference response envelopes +# ============================================================================= + + +class BatchResponse(_BaseModel): + """Response for POST /videos/batch.""" + + videos: list[Video] = Field(default_factory=list) + errors: list[dict] = Field(default_factory=list) + count: int = 0 + + +class CategoriesResponse(_BaseModel): + """Response for /categories.""" + + gl: str + categories: list[ReferenceRow] = Field(default_factory=list) + + +class LanguagesResponse(_BaseModel): + """Response for /languages.""" + + languages: list[ReferenceRow] = Field(default_factory=list) + + +class RegionsResponse(_BaseModel): + """Response for /regions.""" + + regions: list[ReferenceRow] = Field(default_factory=list) + + +class MarketInfo(_BaseModel): + """A single supported scraper market (for /markets).""" + + gl: str + hl: str + name: str + + +class MarketsResponse(_BaseModel): + """Response for /markets.""" + + markets: list[MarketInfo] = Field(default_factory=list) diff --git a/src/scrapebadger/youtube/playlists.py b/src/scrapebadger/youtube/playlists.py new file mode 100644 index 0000000..f8e1db7 --- /dev/null +++ b/src/scrapebadger/youtube/playlists.py @@ -0,0 +1,127 @@ +"""YouTube Playlists API client. + +Provides methods for playlist detail, a continuation page of playlist items, and +auto-generated mix / radio queues. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from scrapebadger.youtube.models import Playlist, PlaylistItemsResponse + +if TYPE_CHECKING: + from scrapebadger._internal.client import BaseClient + + +class PlaylistsClient: + """Client for YouTube playlist and mix endpoints. + + Example: + ```python + async with ScrapeBadger(api_key="key") as client: + playlist = await client.youtube.playlists.get_playlist("PLxxxx") + for item in playlist.videos: + print(item.position, item.title) + + page = await client.youtube.playlists.get_items( + "PLxxxx", continuation=playlist.continuation + ) + ``` + """ + + def __init__(self, client: BaseClient) -> None: + """Initialize playlists client. + + Args: + client: The base HTTP client. + """ + self._client = client + + async def get_playlist( + self, + playlist_id: str, + *, + gl: str | None = None, + hl: str | None = None, + ) -> Playlist: + """Get a playlist's full detail plus the first page of items. + + Args: + playlist_id: The YouTube playlist id. + gl: Content region (US, GB, DE…). + hl: UI language. + + Returns: + Full playlist detail with the first page of items and a continuation token. + + Raises: + NotFoundError: If the playlist doesn't exist. + AuthenticationError: If the API key is invalid. + + Example: + ```python + playlist = await client.youtube.playlists.get_playlist("PLxxxx") + ``` + """ + params: dict[str, Any] = {"gl": gl, "hl": hl} + response = await self._client.get(f"/v1/youtube/playlists/{playlist_id}", params=params) + return Playlist.model_validate(response) + + async def get_items( + self, + playlist_id: str, + *, + continuation: str | None = None, + gl: str | None = None, + hl: str | None = None, + ) -> PlaylistItemsResponse: + """Get a continuation page of playlist items. + + Args: + playlist_id: The YouTube playlist id. + continuation: Pagination token from a previous page. + gl: Content region (US, GB, DE…). + hl: UI language. + + Returns: + Playlist items response with items and a continuation token. + + Example: + ```python + page = await client.youtube.playlists.get_items( + "PLxxxx", continuation="token" + ) + ``` + """ + params: dict[str, Any] = {"continuation": continuation, "gl": gl, "hl": hl} + response = await self._client.get( + f"/v1/youtube/playlists/{playlist_id}/items", params=params + ) + return PlaylistItemsResponse.model_validate(response) + + async def get_mix( + self, + playlist_id: str, + *, + gl: str | None = None, + hl: str | None = None, + ) -> Playlist: + """Resolve an auto-generated mix / radio (RD…) queue. + + Args: + playlist_id: The mix / radio playlist id (typically starts with ``RD``). + gl: Content region (US, GB, DE…). + hl: UI language. + + Returns: + Playlist with ``is_mix=True`` and the resolved queue items. + + Example: + ```python + mix = await client.youtube.playlists.get_mix("RDxxxx") + ``` + """ + params: dict[str, Any] = {"gl": gl, "hl": hl} + response = await self._client.get(f"/v1/youtube/mixes/{playlist_id}", params=params) + return Playlist.model_validate(response) diff --git a/src/scrapebadger/youtube/reference.py b/src/scrapebadger/youtube/reference.py new file mode 100644 index 0000000..a7be2bd --- /dev/null +++ b/src/scrapebadger/youtube/reference.py @@ -0,0 +1,112 @@ +"""YouTube Reference Data API client. + +Provides methods for the static category, language, region, and market lists. +These endpoints are free (0 credits). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from scrapebadger.youtube.models import ( + CategoriesResponse, + LanguagesResponse, + MarketsResponse, + RegionsResponse, +) + +if TYPE_CHECKING: + from scrapebadger._internal.client import BaseClient + + +class ReferenceClient: + """Client for YouTube reference data endpoints (categories, languages, regions, markets). + + Example: + ```python + async with ScrapeBadger(api_key="key") as client: + categories = await client.youtube.reference.list_categories(gl="US") + for c in categories.categories: + print(f"{c.id}: {c.title}") + + markets = await client.youtube.reference.list_markets() + for m in markets.markets: + print(f"{m.gl}/{m.hl}: {m.name}") + ``` + """ + + def __init__(self, client: BaseClient) -> None: + """Initialize reference client. + + Args: + client: The base HTTP client. + """ + self._client = client + + async def list_categories(self, *, gl: str = "US") -> CategoriesResponse: + """List YouTube video categories (Data-API parity). + + Args: + gl: Content region whose category set to return. Defaults to "US". + + Returns: + Categories response with category id/title rows. + + Example: + ```python + result = await client.youtube.reference.list_categories(gl="GB") + for c in result.categories: + print(f"{c.id}: {c.title}") + ``` + """ + params: dict[str, Any] = {"gl": gl} + response = await self._client.get("/v1/youtube/categories", params=params) + return CategoriesResponse.model_validate(response) + + async def list_languages(self) -> LanguagesResponse: + """List supported UI languages (hl codes). + + Returns: + Languages response with language id/title rows. + + Example: + ```python + result = await client.youtube.reference.list_languages() + for lang in result.languages: + print(f"{lang.id}: {lang.title}") + ``` + """ + response = await self._client.get("/v1/youtube/languages") + return LanguagesResponse.model_validate(response) + + async def list_regions(self) -> RegionsResponse: + """List supported content regions (gl codes). + + Returns: + Regions response with region id/title rows. + + Example: + ```python + result = await client.youtube.reference.list_regions() + for region in result.regions: + print(f"{region.id}: {region.title}") + ``` + """ + response = await self._client.get("/v1/youtube/regions") + return RegionsResponse.model_validate(response) + + async def list_markets(self) -> MarketsResponse: + """List the regions the scraper explicitly geo-targets (proxy-pinned). + + Returns: + Markets response with the supported scraper markets. + + Example: + ```python + result = await client.youtube.reference.list_markets() + for m in result.markets: + print(f"{m.gl}/{m.hl}: {m.name}") + ``` + """ + response = await self._client.get("/v1/youtube/markets") + return MarketsResponse.model_validate(response) diff --git a/src/scrapebadger/youtube/search.py b/src/scrapebadger/youtube/search.py new file mode 100644 index 0000000..fab938f --- /dev/null +++ b/src/scrapebadger/youtube/search.py @@ -0,0 +1,219 @@ +"""YouTube Search API client. + +Provides methods for keyword search, YouTube Music search, keyword autocomplete, +hashtag feeds, and the guest home feed. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from scrapebadger.youtube.models import ( + AutocompleteResponse, + HashtagResponse, + HomeResponse, + SearchResponse, +) + +if TYPE_CHECKING: + from scrapebadger._internal.client import BaseClient + + +class SearchClient: + """Client for YouTube search, music search, autocomplete, hashtag, and home feeds. + + Example: + ```python + async with ScrapeBadger(api_key="key") as client: + results = await client.youtube.search.search("lofi hip hop") + for r in results.results: + print(f"{r.position}. {r.title}") + + songs = await client.youtube.search.music("daft punk") + + suggestions = await client.youtube.search.autocomplete("lofi") + for s in suggestions.suggestions: + print(s) + ``` + """ + + def __init__(self, client: BaseClient) -> None: + """Initialize search client. + + Args: + client: The base HTTP client. + """ + self._client = client + + async def search( + self, + query: str, + *, + type: str | None = None, + sort_by: str | None = None, + upload_date: str | None = None, + duration: str | None = None, + features: str | None = None, + gl: str | None = None, + hl: str | None = None, + continuation: str | None = None, + ) -> SearchResponse: + """Search YouTube for videos, channels, and playlists. + + Args: + query: Search keywords. + type: Restrict result type ("video", "channel", "playlist", "movie", "all"). + sort_by: Sort order ("relevance", "date", "views", "rating"). + upload_date: Upload-date filter ("hour", "today", "week", "month", "year"). + duration: Duration filter ("short", "medium", "long"). + features: Comma list of feature filters + (hd,4k,360,vr180,3d,hdr,cc,subtitles,live,location). + gl: Content region (US, GB, DE…). + hl: UI language. + continuation: Pagination token from a previous page. + + Returns: + Search response with matching results, chips, and a continuation token. + + Raises: + AuthenticationError: If the API key is invalid. + ValidationError: If the parameters are invalid. + + Example: + ```python + results = await client.youtube.search.search( + "python tutorial", + type="video", + sort_by="views", + upload_date="month", + ) + ``` + """ + params: dict[str, Any] = { + "query": query, + "type": type, + "sort_by": sort_by, + "upload_date": upload_date, + "duration": duration, + "features": features, + "gl": gl, + "hl": hl, + "continuation": continuation, + } + response = await self._client.get("/v1/youtube/search", params=params) + return SearchResponse.model_validate(response) + + async def music( + self, + query: str, + *, + gl: str | None = None, + hl: str | None = None, + continuation: str | None = None, + ) -> SearchResponse: + """Search YouTube Music (songs/albums/artists/playlists). + + Args: + query: Search keywords. + gl: Content region (US, GB, DE…). + hl: UI language. + continuation: Pagination token from a previous page. + + Returns: + Search response from the WEB_REMIX (YouTube Music) client. + + Example: + ```python + results = await client.youtube.search.music("daft punk") + ``` + """ + params: dict[str, Any] = { + "query": query, + "gl": gl, + "hl": hl, + "continuation": continuation, + } + response = await self._client.get("/v1/youtube/music/search", params=params) + return SearchResponse.model_validate(response) + + async def autocomplete( + self, + query: str, + *, + gl: str | None = None, + hl: str | None = None, + ) -> AutocompleteResponse: + """Get YouTube keyword autocomplete suggestions. + + Args: + query: Partial search query prefix. + gl: Content region (US, GB, DE…). + hl: UI language. + + Returns: + Autocomplete response with keyword suggestions. + + Example: + ```python + result = await client.youtube.search.autocomplete("lofi") + for s in result.suggestions: + print(s) + ``` + """ + params: dict[str, Any] = {"query": query, "gl": gl, "hl": hl} + response = await self._client.get("/v1/youtube/autocomplete", params=params) + return AutocompleteResponse.model_validate(response) + + async def hashtag( + self, + tag: str, + *, + gl: str | None = None, + hl: str | None = None, + continuation: str | None = None, + ) -> HashtagResponse: + """List videos published under a hashtag. + + Args: + tag: The hashtag (with or without a leading ``#``). + gl: Content region (US, GB, DE…). + hl: UI language. + continuation: Pagination token from a previous page. + + Returns: + Hashtag response with results and a continuation token. + + Example: + ```python + result = await client.youtube.search.hashtag("shorts") + ``` + """ + params: dict[str, Any] = {"gl": gl, "hl": hl, "continuation": continuation} + response = await self._client.get(f"/v1/youtube/hashtags/{tag}", params=params) + return HashtagResponse.model_validate(response) + + async def home( + self, + *, + gl: str | None = None, + hl: str | None = None, + continuation: str | None = None, + ) -> HomeResponse: + """Get the guest home / recommendations feed (best-effort). + + Args: + gl: Content region (US, GB, DE…). + hl: UI language. + continuation: Pagination token from a previous page. + + Returns: + Home response with recommended results and a continuation token. + + Example: + ```python + feed = await client.youtube.search.home(gl="US") + ``` + """ + params: dict[str, Any] = {"gl": gl, "hl": hl, "continuation": continuation} + response = await self._client.get("/v1/youtube/home", params=params) + return HomeResponse.model_validate(response) diff --git a/src/scrapebadger/youtube/transcript.py b/src/scrapebadger/youtube/transcript.py new file mode 100644 index 0000000..3de5a4e --- /dev/null +++ b/src/scrapebadger/youtube/transcript.py @@ -0,0 +1,103 @@ +"""YouTube Transcript API client. + +Provides methods for fetching a video transcript and listing caption tracks. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from scrapebadger.youtube.models import CaptionsResponse, Transcript + +if TYPE_CHECKING: + from scrapebadger._internal.client import BaseClient + + +class TranscriptClient: + """Client for YouTube transcript and caption-track endpoints. + + Example: + ```python + async with ScrapeBadger(api_key="key") as client: + transcript = await client.youtube.transcript.get_transcript("dQw4w9WgXcQ") + print(transcript.full_text) + + captions = await client.youtube.transcript.get_captions("dQw4w9WgXcQ") + for track in captions.tracks: + print(track.language, track.language_name) + ``` + """ + + def __init__(self, client: BaseClient) -> None: + """Initialize transcript client. + + Args: + client: The base HTTP client. + """ + self._client = client + + async def get_transcript( + self, + video_id: str, + *, + language: str | None = None, + gl: str | None = None, + hl: str | None = None, + ) -> Transcript: + """Get a video transcript in the selected language. + + Args: + video_id: The YouTube video id. + language: BCP-47 language code to prefer (e.g. "en", "es"). + gl: Content region (US, GB, DE…). + hl: UI language. + + Returns: + Transcript with timed segments, full text, and SRT/VTT renderings. + + Raises: + NotFoundError: If the video doesn't exist. + AuthenticationError: If the API key is invalid. + + Example: + ```python + transcript = await client.youtube.transcript.get_transcript( + "dQw4w9WgXcQ", language="en" + ) + for seg in transcript.segments: + print(seg.start_time_text, seg.text) + ``` + """ + params: dict[str, Any] = {"language": language, "gl": gl, "hl": hl} + response = await self._client.get( + f"/v1/youtube/videos/{video_id}/transcript", params=params + ) + return Transcript.model_validate(response) + + async def get_captions( + self, + video_id: str, + *, + gl: str | None = None, + hl: str | None = None, + ) -> CaptionsResponse: + """List the available caption tracks for a video. + + Args: + video_id: The YouTube video id. + gl: Content region (US, GB, DE…). + hl: UI language. + + Returns: + Captions response with caption tracks and translation languages. + + Example: + ```python + captions = await client.youtube.transcript.get_captions("dQw4w9WgXcQ") + for track in captions.tracks: + print(track.language, track.type) + ``` + """ + params: dict[str, Any] = {"gl": gl, "hl": hl} + response = await self._client.get(f"/v1/youtube/videos/{video_id}/captions", params=params) + return CaptionsResponse.model_validate(response) diff --git a/src/scrapebadger/youtube/trending.py b/src/scrapebadger/youtube/trending.py new file mode 100644 index 0000000..5a18b52 --- /dev/null +++ b/src/scrapebadger/youtube/trending.py @@ -0,0 +1,98 @@ +"""YouTube Trending API client. + +Provides methods for the trending videos feed (by category) and trending Shorts. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from scrapebadger.youtube.models import TrendingResponse + +if TYPE_CHECKING: + from scrapebadger._internal.client import BaseClient + + +class TrendingClient: + """Client for YouTube trending endpoints (videos, shorts). + + Example: + ```python + async with ScrapeBadger(api_key="key") as client: + trending = await client.youtube.trending.trending(type="music") + for item in trending.items: + print(item.position, item.title) + + shorts = await client.youtube.trending.shorts(gl="US") + ``` + """ + + def __init__(self, client: BaseClient) -> None: + """Initialize trending client. + + Args: + client: The base HTTP client. + """ + self._client = client + + async def trending( + self, + *, + type: str = "now", + gl: str | None = None, + hl: str | None = None, + continuation: str | None = None, + ) -> TrendingResponse: + """Get the trending videos feed. + + Args: + type: Trending category ("now", "music", "gaming", "movies"). Defaults to "now". + gl: Content region (US, GB, DE…). + hl: UI language. + continuation: Pagination token from a previous page. + + Returns: + Trending response with trending items and a continuation token. + + Raises: + AuthenticationError: If the API key is invalid. + + Example: + ```python + trending = await client.youtube.trending.trending(type="gaming", gl="GB") + ``` + """ + params: dict[str, Any] = { + "type": type, + "gl": gl, + "hl": hl, + "continuation": continuation, + } + response = await self._client.get("/v1/youtube/trending", params=params) + return TrendingResponse.model_validate(response) + + async def shorts( + self, + *, + gl: str | None = None, + hl: str | None = None, + continuation: str | None = None, + ) -> TrendingResponse: + """Get the trending Shorts feed. + + Args: + gl: Content region (US, GB, DE…). + hl: UI language. + continuation: Pagination token from a previous page. + + Returns: + Trending response with trending Shorts and a continuation token. + + Example: + ```python + shorts = await client.youtube.trending.shorts(gl="US") + ``` + """ + params: dict[str, Any] = {"gl": gl, "hl": hl, "continuation": continuation} + response = await self._client.get("/v1/youtube/trending/shorts", params=params) + return TrendingResponse.model_validate(response) diff --git a/src/scrapebadger/youtube/videos.py b/src/scrapebadger/youtube/videos.py new file mode 100644 index 0000000..f4378c8 --- /dev/null +++ b/src/scrapebadger/youtube/videos.py @@ -0,0 +1,281 @@ +"""YouTube Videos API client. + +Provides methods for video detail, batch detail, related videos, streams, live +chat, oEmbed metadata, and single Shorts (detail + by-sound). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from scrapebadger.youtube.models import ( + BatchResponse, + ChannelTabResponse, + LiveChatResponse, + OEmbed, + RelatedResponse, + Short, + StreamingData, + Video, +) + +if TYPE_CHECKING: + from scrapebadger._internal.client import BaseClient + + +class VideosClient: + """Client for YouTube video, short, stream, live-chat, and oEmbed endpoints. + + Example: + ```python + async with ScrapeBadger(api_key="key") as client: + video = await client.youtube.videos.get_video("dQw4w9WgXcQ") + print(video.title, video.view_count) + + related = await client.youtube.videos.get_related("dQw4w9WgXcQ") + + batch = await client.youtube.videos.batch(["id1", "id2"]) + ``` + """ + + def __init__(self, client: BaseClient) -> None: + """Initialize videos client. + + Args: + client: The base HTTP client. + """ + self._client = client + + async def get_video( + self, + video_id: str, + *, + gl: str | None = None, + hl: str | None = None, + ) -> Video: + """Get a single video's full detail (merged ``player`` + ``next``). + + Args: + video_id: The YouTube video id. + gl: Content region (US, GB, DE…). + hl: UI language. + + Returns: + Full video detail including engagement, channel, chapters, and shelves. + + Raises: + NotFoundError: If the video doesn't exist or is unavailable. + AuthenticationError: If the API key is invalid. + + Example: + ```python + video = await client.youtube.videos.get_video("dQw4w9WgXcQ") + print(f"{video.title}: {video.view_count:,} views") + ``` + """ + params: dict[str, Any] = {"gl": gl, "hl": hl} + response = await self._client.get(f"/v1/youtube/videos/{video_id}", params=params) + return Video.model_validate(response) + + async def batch( + self, + video_ids: list[str], + *, + gl: str | None = None, + hl: str | None = None, + ) -> BatchResponse: + """Fetch detail for up to 50 videos concurrently. + + Args: + video_ids: A list of YouTube video ids (truncated to 50 server-side). + gl: Content region (US, GB, DE…). + hl: UI language. + + Returns: + Batch response with parsed videos, per-id errors, and a success count. + + Example: + ```python + result = await client.youtube.videos.batch(["dQw4w9WgXcQ", "9bZkp7q19f0"]) + print(f"{result.count} ok, {len(result.errors)} failed") + ``` + """ + payload: dict[str, Any] = {"video_ids": video_ids} + if gl is not None: + payload["gl"] = gl + if hl is not None: + payload["hl"] = hl + response = await self._client.post("/v1/youtube/videos/batch", json=payload) + return BatchResponse.model_validate(response) + + async def get_related( + self, + video_id: str, + *, + continuation: str | None = None, + gl: str | None = None, + hl: str | None = None, + ) -> RelatedResponse: + """Get videos related to a video (secondaryResults). + + Args: + video_id: The YouTube video id. + continuation: Pagination token from a previous page. + gl: Content region (US, GB, DE…). + hl: UI language. + + Returns: + Related response with result cards and a continuation token. + + Example: + ```python + related = await client.youtube.videos.get_related("dQw4w9WgXcQ") + ``` + """ + params: dict[str, Any] = {"continuation": continuation, "gl": gl, "hl": hl} + response = await self._client.get(f"/v1/youtube/videos/{video_id}/related", params=params) + return RelatedResponse.model_validate(response) + + async def get_streams( + self, + video_id: str, + *, + gl: str | None = None, + client: str = "IOS", + ) -> StreamingData: + """Get stream/format metadata for a video (best-effort URLs). + + Args: + video_id: The YouTube video id. + gl: Content region (US, GB, DE…). + client: InnerTube client context ("IOS", "ANDROID", "WEB"). Defaults to "IOS". + + Returns: + Streaming data with muxed and adaptive formats. Media URLs may be + PO-token gated and are therefore best-effort. + + Example: + ```python + streams = await client.youtube.videos.get_streams("dQw4w9WgXcQ") + for fmt in streams.adaptive_formats: + print(fmt.itag, fmt.quality_label) + ``` + """ + params: dict[str, Any] = {"gl": gl, "client": client} + response = await self._client.get(f"/v1/youtube/videos/{video_id}/streams", params=params) + return StreamingData.model_validate(response) + + async def get_live_chat( + self, + video_id: str, + *, + continuation: str | None = None, + replay: bool = False, + gl: str | None = None, + hl: str | None = None, + ) -> LiveChatResponse: + """Get live-chat (or chat-replay) messages for a stream. + + Args: + video_id: The YouTube video id. + continuation: Pagination token from a previous page. + replay: Fetch chat replay for a finished stream. Defaults to False. + gl: Content region (US, GB, DE…). + hl: UI language. + + Returns: + Live chat response with messages and a continuation token. + + Example: + ```python + chat = await client.youtube.videos.get_live_chat("liveVideoId") + for m in chat.messages: + print(f"{m.author}: {m.text}") + ``` + """ + params: dict[str, Any] = { + "continuation": continuation, + "replay": replay, + "gl": gl, + "hl": hl, + } + response = await self._client.get(f"/v1/youtube/videos/{video_id}/live_chat", params=params) + return LiveChatResponse.model_validate(response) + + async def oembed(self, url: str) -> OEmbed: + """Get public oEmbed metadata for a YouTube URL. + + Args: + url: A YouTube video/playlist/channel URL. + + Returns: + oEmbed response with title, author, thumbnail, and embed HTML. + + Example: + ```python + meta = await client.youtube.videos.oembed( + "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + ) + print(meta.title, meta.author_name) + ``` + """ + params: dict[str, Any] = {"url": url} + response = await self._client.get("/v1/youtube/oembed", params=params) + return OEmbed.model_validate(response) + + async def get_short( + self, + video_id: str, + *, + gl: str | None = None, + hl: str | None = None, + ) -> Short: + """Get a single Short's detail. + + Args: + video_id: The Shorts video id. + gl: Content region (US, GB, DE…). + hl: UI language. + + Returns: + Short detail (a Video subtype with ``is_short=True``). + + Raises: + NotFoundError: If the short doesn't exist or is unavailable. + + Example: + ```python + short = await client.youtube.videos.get_short("shortVideoId") + ``` + """ + params: dict[str, Any] = {"gl": gl, "hl": hl} + response = await self._client.get(f"/v1/youtube/shorts/{video_id}", params=params) + return Short.model_validate(response) + + async def shorts_by_sound( + self, + sound_id: str, + *, + continuation: str | None = None, + gl: str | None = None, + hl: str | None = None, + ) -> ChannelTabResponse: + """List Shorts attributed to a given sound/music id (best-effort). + + Args: + sound_id: The sound/music id. + continuation: Pagination token from a previous page. + gl: Content region (US, GB, DE…). + hl: UI language. + + Returns: + A tab response with Shorts items and a continuation token. + + Example: + ```python + result = await client.youtube.videos.shorts_by_sound("soundId") + ``` + """ + params: dict[str, Any] = {"continuation": continuation, "gl": gl, "hl": hl} + response = await self._client.get(f"/v1/youtube/shorts/by_sound/{sound_id}", params=params) + return ChannelTabResponse.model_validate(response)