cli-web-youtube 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cli_web_youtube-0.1.0/PKG-INFO +11 -0
- cli_web_youtube-0.1.0/cli_web/youtube/README.md +49 -0
- cli_web_youtube-0.1.0/cli_web/youtube/__init__.py +3 -0
- cli_web_youtube-0.1.0/cli_web/youtube/__main__.py +6 -0
- cli_web_youtube-0.1.0/cli_web/youtube/commands/__init__.py +0 -0
- cli_web_youtube-0.1.0/cli_web/youtube/commands/channel.py +42 -0
- cli_web_youtube-0.1.0/cli_web/youtube/commands/search.py +34 -0
- cli_web_youtube-0.1.0/cli_web/youtube/commands/trending.py +41 -0
- cli_web_youtube-0.1.0/cli_web/youtube/commands/video.py +41 -0
- cli_web_youtube-0.1.0/cli_web/youtube/core/__init__.py +0 -0
- cli_web_youtube-0.1.0/cli_web/youtube/core/client.py +244 -0
- cli_web_youtube-0.1.0/cli_web/youtube/core/exceptions.py +63 -0
- cli_web_youtube-0.1.0/cli_web/youtube/core/models.py +149 -0
- cli_web_youtube-0.1.0/cli_web/youtube/tests/TEST.md +115 -0
- cli_web_youtube-0.1.0/cli_web/youtube/tests/__init__.py +0 -0
- cli_web_youtube-0.1.0/cli_web/youtube/tests/test_core.py +412 -0
- cli_web_youtube-0.1.0/cli_web/youtube/tests/test_e2e.py +293 -0
- cli_web_youtube-0.1.0/cli_web/youtube/utils/__init__.py +0 -0
- cli_web_youtube-0.1.0/cli_web/youtube/utils/doctor.py +188 -0
- cli_web_youtube-0.1.0/cli_web/youtube/utils/helpers.py +60 -0
- cli_web_youtube-0.1.0/cli_web/youtube/utils/mcp_server.py +290 -0
- cli_web_youtube-0.1.0/cli_web/youtube/utils/output.py +68 -0
- cli_web_youtube-0.1.0/cli_web/youtube/utils/repl_skin.py +486 -0
- cli_web_youtube-0.1.0/cli_web/youtube/youtube_cli.py +134 -0
- cli_web_youtube-0.1.0/cli_web_youtube.egg-info/PKG-INFO +11 -0
- cli_web_youtube-0.1.0/cli_web_youtube.egg-info/SOURCES.txt +30 -0
- cli_web_youtube-0.1.0/cli_web_youtube.egg-info/dependency_links.txt +1 -0
- cli_web_youtube-0.1.0/cli_web_youtube.egg-info/entry_points.txt +2 -0
- cli_web_youtube-0.1.0/cli_web_youtube.egg-info/requires.txt +3 -0
- cli_web_youtube-0.1.0/cli_web_youtube.egg-info/top_level.txt +1 -0
- cli_web_youtube-0.1.0/setup.cfg +4 -0
- cli_web_youtube-0.1.0/setup.py +22 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cli-web-youtube
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI for YouTube — search videos, get details, browse trending, explore channels
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: click>=8.0
|
|
7
|
+
Requires-Dist: httpx
|
|
8
|
+
Requires-Dist: prompt_toolkit>=3.0
|
|
9
|
+
Dynamic: requires-dist
|
|
10
|
+
Dynamic: requires-python
|
|
11
|
+
Dynamic: summary
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# cli-web-youtube
|
|
2
|
+
|
|
3
|
+
> Generated by [CLI-Anything-Web](../../../../cli-anything-web-plugin/) from [youtube.com](https://www.youtube.com)
|
|
4
|
+
|
|
5
|
+
Search YouTube videos, get video details, browse trending content, and explore channels — all from the command line.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install cli-web-youtube
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# Search for videos
|
|
17
|
+
cli-web-youtube search videos "python tutorial" --limit 10
|
|
18
|
+
|
|
19
|
+
# Get video details
|
|
20
|
+
cli-web-youtube video get dQw4w9WgXcQ --json
|
|
21
|
+
|
|
22
|
+
# Browse trending
|
|
23
|
+
cli-web-youtube trending list --category music --limit 10
|
|
24
|
+
|
|
25
|
+
# Channel info
|
|
26
|
+
cli-web-youtube channel get @MrBeast --json
|
|
27
|
+
|
|
28
|
+
# Interactive REPL
|
|
29
|
+
cli-web-youtube
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Auth
|
|
33
|
+
|
|
34
|
+
No authentication required — all commands access public YouTube content.
|
|
35
|
+
|
|
36
|
+
## JSON Output
|
|
37
|
+
|
|
38
|
+
All commands support `--json` for structured output:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
cli-web-youtube search videos "AI" --limit 3 --json
|
|
42
|
+
cli-web-youtube video get dQw4w9WgXcQ --json
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Testing
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
python -m pytest cli_web/youtube/tests/ -v
|
|
49
|
+
```
|
|
File without changes
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Channel command for cli-web-youtube."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from ..core.client import YouTubeClient
|
|
8
|
+
from ..utils.helpers import handle_errors, print_json, resolve_json_mode
|
|
9
|
+
from ..utils.output import print_channel_detail
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.group("channel")
|
|
13
|
+
def channel_group():
|
|
14
|
+
"""Browse YouTube channels."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@channel_group.command("get")
|
|
18
|
+
@click.argument("handle")
|
|
19
|
+
@click.option("--json", "use_json", is_flag=True, help="Output as JSON.")
|
|
20
|
+
def channel_get(handle, use_json):
|
|
21
|
+
"""Get channel info and recent videos.
|
|
22
|
+
|
|
23
|
+
HANDLE can be @username, channel ID (UC...), or URL.
|
|
24
|
+
|
|
25
|
+
Example: channel get @MrBeast
|
|
26
|
+
"""
|
|
27
|
+
# Extract handle from URL if needed
|
|
28
|
+
if "youtube.com" in handle:
|
|
29
|
+
if "/@" in handle:
|
|
30
|
+
handle = handle.split("/@")[1].split("/")[0]
|
|
31
|
+
handle = f"@{handle}"
|
|
32
|
+
elif "/channel/" in handle:
|
|
33
|
+
handle = handle.split("/channel/")[1].split("/")[0]
|
|
34
|
+
|
|
35
|
+
use_json = resolve_json_mode(use_json)
|
|
36
|
+
with handle_errors(json_mode=use_json):
|
|
37
|
+
client = YouTubeClient()
|
|
38
|
+
result = client.channel(handle)
|
|
39
|
+
if use_json:
|
|
40
|
+
print_json(result)
|
|
41
|
+
else:
|
|
42
|
+
print_channel_detail(result)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Search command for cli-web-youtube."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from ..core.client import YouTubeClient
|
|
8
|
+
from ..utils.helpers import handle_errors, print_json, resolve_json_mode
|
|
9
|
+
from ..utils.output import print_videos_table
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.group("search")
|
|
13
|
+
def search_group():
|
|
14
|
+
"""Search YouTube videos."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@search_group.command("videos")
|
|
18
|
+
@click.argument("query")
|
|
19
|
+
@click.option("--limit", "-l", default=10, type=int, help="Max results (default 10).")
|
|
20
|
+
@click.option("--json", "use_json", is_flag=True, help="Output as JSON.")
|
|
21
|
+
def search_videos(query, limit, use_json):
|
|
22
|
+
"""Search for videos by query.
|
|
23
|
+
|
|
24
|
+
Example: search videos "python tutorial"
|
|
25
|
+
"""
|
|
26
|
+
use_json = resolve_json_mode(use_json)
|
|
27
|
+
with handle_errors(json_mode=use_json):
|
|
28
|
+
client = YouTubeClient()
|
|
29
|
+
result = client.search(query, limit=limit)
|
|
30
|
+
if use_json:
|
|
31
|
+
print_json(result)
|
|
32
|
+
else:
|
|
33
|
+
click.echo(f'\n Search: "{query}" ({result["estimated_results"]:,} results)\n')
|
|
34
|
+
print_videos_table(result["videos"])
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Trending command for cli-web-youtube."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from ..core.client import YouTubeClient
|
|
8
|
+
from ..utils.helpers import handle_errors, print_json, resolve_json_mode
|
|
9
|
+
from ..utils.output import print_videos_table
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.group("trending")
|
|
13
|
+
def trending_group():
|
|
14
|
+
"""Browse trending YouTube videos."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@trending_group.command("list")
|
|
18
|
+
@click.option(
|
|
19
|
+
"--category",
|
|
20
|
+
"-c",
|
|
21
|
+
default="now",
|
|
22
|
+
type=click.Choice(["now", "music", "gaming", "movies"], case_sensitive=False),
|
|
23
|
+
help="Category filter (default: now).",
|
|
24
|
+
)
|
|
25
|
+
@click.option("--limit", "-l", default=20, type=int, help="Max results (default 20).")
|
|
26
|
+
@click.option("--json", "use_json", is_flag=True, help="Output as JSON.")
|
|
27
|
+
def trending_list(category, limit, use_json):
|
|
28
|
+
"""List trending/popular videos.
|
|
29
|
+
|
|
30
|
+
Example: trending list --category music --limit 10
|
|
31
|
+
"""
|
|
32
|
+
use_json = resolve_json_mode(use_json)
|
|
33
|
+
with handle_errors(json_mode=use_json):
|
|
34
|
+
client = YouTubeClient()
|
|
35
|
+
videos = client.trending(category=category)
|
|
36
|
+
videos = videos[:limit]
|
|
37
|
+
if use_json:
|
|
38
|
+
print_json({"videos": videos, "count": len(videos), "category": category})
|
|
39
|
+
else:
|
|
40
|
+
click.echo(f"\n Trending on YouTube — {category.title()} ({len(videos)} videos)\n")
|
|
41
|
+
print_videos_table(videos)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Video command for cli-web-youtube."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from ..core.client import YouTubeClient
|
|
8
|
+
from ..utils.helpers import handle_errors, print_json, resolve_json_mode
|
|
9
|
+
from ..utils.output import print_video_detail
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.group("video")
|
|
13
|
+
def video_group():
|
|
14
|
+
"""Get video details."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@video_group.command("get")
|
|
18
|
+
@click.argument("video_id")
|
|
19
|
+
@click.option("--json", "use_json", is_flag=True, help="Output as JSON.")
|
|
20
|
+
def video_get(video_id, use_json):
|
|
21
|
+
"""Get details for a specific video.
|
|
22
|
+
|
|
23
|
+
VIDEO_ID can be the 11-character ID or a full YouTube URL.
|
|
24
|
+
|
|
25
|
+
Example: video get dQw4w9WgXcQ
|
|
26
|
+
"""
|
|
27
|
+
# Extract video ID from URL if needed
|
|
28
|
+
if "youtube.com" in video_id or "youtu.be" in video_id:
|
|
29
|
+
if "v=" in video_id:
|
|
30
|
+
video_id = video_id.split("v=")[1].split("&")[0]
|
|
31
|
+
elif "youtu.be/" in video_id:
|
|
32
|
+
video_id = video_id.split("youtu.be/")[1].split("?")[0]
|
|
33
|
+
|
|
34
|
+
use_json = resolve_json_mode(use_json)
|
|
35
|
+
with handle_errors(json_mode=use_json):
|
|
36
|
+
client = YouTubeClient()
|
|
37
|
+
result = client.video_detail(video_id)
|
|
38
|
+
if use_json:
|
|
39
|
+
print_json(result)
|
|
40
|
+
else:
|
|
41
|
+
print_video_detail(result)
|
|
File without changes
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""HTTP client for YouTube InnerTube API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from .exceptions import (
|
|
8
|
+
NetworkError,
|
|
9
|
+
NotFoundError,
|
|
10
|
+
ParseError,
|
|
11
|
+
RateLimitError,
|
|
12
|
+
ServerError,
|
|
13
|
+
YouTubeError,
|
|
14
|
+
)
|
|
15
|
+
from .models import (
|
|
16
|
+
format_channel,
|
|
17
|
+
format_video_detail,
|
|
18
|
+
format_video_from_renderer,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
INNERTUBE_URL = "https://www.youtube.com/youtubei/v1"
|
|
22
|
+
INNERTUBE_CONTEXT = {
|
|
23
|
+
"client": {
|
|
24
|
+
"clientName": "WEB",
|
|
25
|
+
"clientVersion": "2.20260326.01.00",
|
|
26
|
+
"hl": "en",
|
|
27
|
+
"gl": "US",
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class YouTubeClient:
|
|
33
|
+
"""Client for YouTube's InnerTube API."""
|
|
34
|
+
|
|
35
|
+
def __init__(self):
|
|
36
|
+
self._session = httpx.Client(
|
|
37
|
+
timeout=15.0,
|
|
38
|
+
headers={
|
|
39
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
|
40
|
+
"Content-Type": "application/json",
|
|
41
|
+
"Accept": "application/json",
|
|
42
|
+
},
|
|
43
|
+
follow_redirects=True,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def __enter__(self):
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
def __exit__(self, *args):
|
|
50
|
+
self._session.close()
|
|
51
|
+
|
|
52
|
+
def close(self):
|
|
53
|
+
self._session.close()
|
|
54
|
+
|
|
55
|
+
# ── Internal ──────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
def _post(self, endpoint: str, body: dict) -> dict:
|
|
58
|
+
"""POST to InnerTube API with context."""
|
|
59
|
+
url = f"{INNERTUBE_URL}/{endpoint}?prettyPrint=false"
|
|
60
|
+
payload = {"context": INNERTUBE_CONTEXT, **body}
|
|
61
|
+
try:
|
|
62
|
+
resp = self._session.post(url, json=payload)
|
|
63
|
+
except httpx.TimeoutException as exc:
|
|
64
|
+
raise NetworkError(f"Request timed out: {exc}") from exc
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
raise NetworkError(f"Request failed: {exc}") from exc
|
|
67
|
+
return self._handle_response(resp, endpoint)
|
|
68
|
+
|
|
69
|
+
def _handle_response(self, resp: httpx.Response, endpoint: str = "") -> dict:
|
|
70
|
+
if resp.status_code == 404:
|
|
71
|
+
raise NotFoundError(f"Not found: {endpoint}")
|
|
72
|
+
if resp.status_code == 429:
|
|
73
|
+
retry = resp.headers.get("retry-after")
|
|
74
|
+
raise RateLimitError(
|
|
75
|
+
retry_after=float(retry) if retry else None,
|
|
76
|
+
)
|
|
77
|
+
if resp.status_code >= 500:
|
|
78
|
+
raise ServerError(
|
|
79
|
+
f"YouTube server error: HTTP {resp.status_code}",
|
|
80
|
+
status_code=resp.status_code,
|
|
81
|
+
)
|
|
82
|
+
if resp.status_code >= 400:
|
|
83
|
+
raise YouTubeError(f"HTTP {resp.status_code}: {resp.text[:200]}")
|
|
84
|
+
try:
|
|
85
|
+
return resp.json()
|
|
86
|
+
except Exception as exc:
|
|
87
|
+
raise ParseError(f"Failed to parse response: {exc}") from exc
|
|
88
|
+
|
|
89
|
+
# ── Search ────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
def search(self, query: str, limit: int = 20) -> dict:
|
|
92
|
+
"""Search YouTube videos."""
|
|
93
|
+
resp = self._post("search", {"query": query})
|
|
94
|
+
|
|
95
|
+
contents = (
|
|
96
|
+
resp.get("contents", {})
|
|
97
|
+
.get("twoColumnSearchResultsRenderer", {})
|
|
98
|
+
.get("primaryContents", {})
|
|
99
|
+
.get("sectionListRenderer", {})
|
|
100
|
+
.get("contents", [])
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
videos = []
|
|
104
|
+
for section in contents:
|
|
105
|
+
items = section.get("itemSectionRenderer", {}).get("contents", [])
|
|
106
|
+
for item in items:
|
|
107
|
+
renderer = item.get("videoRenderer")
|
|
108
|
+
if renderer:
|
|
109
|
+
videos.append(format_video_from_renderer(renderer))
|
|
110
|
+
if len(videos) >= limit:
|
|
111
|
+
break
|
|
112
|
+
if len(videos) >= limit:
|
|
113
|
+
break
|
|
114
|
+
|
|
115
|
+
estimated = resp.get("estimatedResults", "0")
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
"query": query,
|
|
119
|
+
"estimated_results": int(estimated),
|
|
120
|
+
"videos": videos,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
# ── Video detail ──────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
def video_detail(self, video_id: str) -> dict:
|
|
126
|
+
"""Get video details via player endpoint."""
|
|
127
|
+
resp = self._post("player", {"videoId": video_id})
|
|
128
|
+
video_details = resp.get("videoDetails")
|
|
129
|
+
if not video_details:
|
|
130
|
+
raise NotFoundError(f"Video not found: {video_id}")
|
|
131
|
+
microformat = resp.get("microformat")
|
|
132
|
+
return format_video_detail(video_details, microformat)
|
|
133
|
+
|
|
134
|
+
# ── Trending ──────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
def trending(self, category: str = "now") -> list[dict]:
|
|
137
|
+
"""Get trending/popular videos via search with sort by view count.
|
|
138
|
+
|
|
139
|
+
YouTube's trending page requires auth for non-logged-in users,
|
|
140
|
+
so we use the search API with popular sort filters as a proxy.
|
|
141
|
+
"""
|
|
142
|
+
# Map categories to search queries that surface popular content
|
|
143
|
+
queries = {
|
|
144
|
+
"now": "", # empty query with sort=viewCount returns popular recent videos
|
|
145
|
+
"music": "music",
|
|
146
|
+
"gaming": "gaming",
|
|
147
|
+
"movies": "movies",
|
|
148
|
+
}
|
|
149
|
+
query = queries.get(category, category)
|
|
150
|
+
|
|
151
|
+
# Use search with empty/broad query — returns popular videos
|
|
152
|
+
resp = self._post(
|
|
153
|
+
"search",
|
|
154
|
+
{
|
|
155
|
+
"query": query if query else "trending",
|
|
156
|
+
"params": "CAMSAhAB", # sort by upload date, filter videos only
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
contents = (
|
|
161
|
+
resp.get("contents", {})
|
|
162
|
+
.get("twoColumnSearchResultsRenderer", {})
|
|
163
|
+
.get("primaryContents", {})
|
|
164
|
+
.get("sectionListRenderer", {})
|
|
165
|
+
.get("contents", [])
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
videos = []
|
|
169
|
+
for section in contents:
|
|
170
|
+
items = section.get("itemSectionRenderer", {}).get("contents", [])
|
|
171
|
+
for item in items:
|
|
172
|
+
renderer = item.get("videoRenderer")
|
|
173
|
+
if renderer:
|
|
174
|
+
videos.append(format_video_from_renderer(renderer))
|
|
175
|
+
|
|
176
|
+
return videos
|
|
177
|
+
|
|
178
|
+
# ── Channel ───────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
def channel(self, handle: str) -> dict:
|
|
181
|
+
"""Get channel info via browse endpoint.
|
|
182
|
+
|
|
183
|
+
Accepts @handle, channel ID (UC...), or /c/name format.
|
|
184
|
+
"""
|
|
185
|
+
# For @handles, scrape the channel page HTML to get ytInitialData
|
|
186
|
+
# This is more reliable than the browse API which needs an API key
|
|
187
|
+
import json as _json
|
|
188
|
+
|
|
189
|
+
clean = handle.lstrip("@")
|
|
190
|
+
if handle.startswith("UC"):
|
|
191
|
+
channel_url = f"https://www.youtube.com/channel/{handle}"
|
|
192
|
+
else:
|
|
193
|
+
channel_url = f"https://www.youtube.com/@{clean}"
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
page_resp = self._session.get(channel_url)
|
|
197
|
+
except Exception as exc:
|
|
198
|
+
raise NetworkError(f"Failed to fetch channel: {exc}") from exc
|
|
199
|
+
|
|
200
|
+
if page_resp.status_code == 404:
|
|
201
|
+
raise NotFoundError(f"Channel not found: {handle}")
|
|
202
|
+
|
|
203
|
+
text = page_resp.text
|
|
204
|
+
marker = "var ytInitialData = "
|
|
205
|
+
idx = text.find(marker)
|
|
206
|
+
if idx < 0:
|
|
207
|
+
raise ParseError(f"Could not find channel data for: {handle}")
|
|
208
|
+
|
|
209
|
+
start = idx + len(marker)
|
|
210
|
+
end = text.find(";</script>", start)
|
|
211
|
+
if end < 0:
|
|
212
|
+
end = text.find(";\n", start)
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
resp = _json.loads(text[start:end])
|
|
216
|
+
except _json.JSONDecodeError as exc:
|
|
217
|
+
raise ParseError(f"Failed to parse channel data: {exc}") from exc
|
|
218
|
+
header = resp.get("header", {})
|
|
219
|
+
metadata = resp.get("metadata", {}).get("channelMetadataRenderer", {})
|
|
220
|
+
result = format_channel(header)
|
|
221
|
+
if "channel_id" in result and not result["channel_id"]:
|
|
222
|
+
result["channel_id"] = metadata.get("externalId", "")
|
|
223
|
+
if not result.get("url"):
|
|
224
|
+
result["url"] = metadata.get("channelUrl", channel_url)
|
|
225
|
+
|
|
226
|
+
# Extract recent videos from tabs
|
|
227
|
+
tabs = resp.get("contents", {}).get("twoColumnBrowseResultsRenderer", {}).get("tabs", [])
|
|
228
|
+
recent_videos = []
|
|
229
|
+
for tab in tabs:
|
|
230
|
+
tab_content = tab.get("tabRenderer", {}).get("content", {})
|
|
231
|
+
sections = tab_content.get("sectionListRenderer", {}).get(
|
|
232
|
+
"contents", []
|
|
233
|
+
) or tab_content.get("richGridRenderer", {}).get("contents", [])
|
|
234
|
+
for section in sections[:10]:
|
|
235
|
+
# richItemRenderer wraps videoRenderer in newer layouts
|
|
236
|
+
rich = section.get("richItemRenderer", {}).get("content", {})
|
|
237
|
+
renderer = rich.get("videoRenderer") or section.get("videoRenderer")
|
|
238
|
+
if renderer:
|
|
239
|
+
recent_videos.append(format_video_from_renderer(renderer))
|
|
240
|
+
if recent_videos:
|
|
241
|
+
break
|
|
242
|
+
|
|
243
|
+
result["recent_videos"] = recent_videos[:10]
|
|
244
|
+
return result
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Domain exception hierarchy for cli-web-youtube."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class YouTubeError(Exception):
|
|
5
|
+
"""Base for all YouTube CLI errors."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, message: str, code: str = "YOUTUBE_ERROR"):
|
|
8
|
+
self.message = message
|
|
9
|
+
self.code = code
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
|
|
12
|
+
def to_dict(self) -> dict:
|
|
13
|
+
return {"error": True, "code": self.code, "message": self.message}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AuthError(YouTubeError):
|
|
17
|
+
"""Authentication required or expired."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, message: str = "Authentication required.", recoverable: bool = False):
|
|
20
|
+
self.recoverable = recoverable
|
|
21
|
+
super().__init__(message, "AUTH_EXPIRED")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RateLimitError(YouTubeError):
|
|
25
|
+
"""429 — retry with backoff."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, message: str = "Rate limited by YouTube.", retry_after: float | None = None):
|
|
28
|
+
self.retry_after = retry_after
|
|
29
|
+
super().__init__(message, "RATE_LIMITED")
|
|
30
|
+
|
|
31
|
+
def to_dict(self) -> dict:
|
|
32
|
+
d = super().to_dict()
|
|
33
|
+
d["retry_after"] = self.retry_after
|
|
34
|
+
return d
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class NetworkError(YouTubeError):
|
|
38
|
+
"""Connection/DNS/timeout errors."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, message: str):
|
|
41
|
+
super().__init__(message, "NETWORK_ERROR")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ServerError(YouTubeError):
|
|
45
|
+
"""5xx responses."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, message: str, status_code: int = 500):
|
|
48
|
+
self.status_code = status_code
|
|
49
|
+
super().__init__(message, "SERVER_ERROR")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class NotFoundError(YouTubeError):
|
|
53
|
+
"""404 — resource not found."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, message: str = "Resource not found"):
|
|
56
|
+
super().__init__(message, "NOT_FOUND")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ParseError(YouTubeError):
|
|
60
|
+
"""Failed to parse YouTube response."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, message: str = "Failed to parse YouTube response"):
|
|
63
|
+
super().__init__(message, "PARSE_ERROR")
|