stophy 0.1.0__py3-none-any.whl
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.
- stophy/__init__.py +6 -0
- stophy/client.py +298 -0
- stophy/errors.py +22 -0
- stophy/types.py +423 -0
- stophy-0.1.0.dist-info/METADATA +100 -0
- stophy-0.1.0.dist-info/RECORD +7 -0
- stophy-0.1.0.dist-info/WHEEL +4 -0
stophy/__init__.py
ADDED
stophy/client.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
import time
|
|
5
|
+
from email.utils import parsedate_to_datetime
|
|
6
|
+
from typing import Any, Dict, List, Literal, Mapping, Optional, overload
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .errors import StophyError
|
|
11
|
+
from .types import (
|
|
12
|
+
ChannelResponse,
|
|
13
|
+
CommentsResponse,
|
|
14
|
+
CreditsResponse,
|
|
15
|
+
LiveChatResponse,
|
|
16
|
+
LogsResponse,
|
|
17
|
+
PlaylistResponse,
|
|
18
|
+
SearchResponse,
|
|
19
|
+
SuggestResponse,
|
|
20
|
+
TranscriptResponse,
|
|
21
|
+
UsageResponse,
|
|
22
|
+
VideoDetailsResponse,
|
|
23
|
+
VideoResponse,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
DEFAULT_BASE_URL = "https://api.stophy.dev"
|
|
27
|
+
RETRYABLE_STATUS = {429, 500, 502, 503, 504}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _compact(params: Mapping[str, Any]) -> Dict[str, Any]:
|
|
31
|
+
"""Drop keys whose value is None so we only send what the caller set."""
|
|
32
|
+
return {k: v for k, v in params.items() if v is not None}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Stophy:
|
|
36
|
+
"""Client for Stophy — YouTube context API for AI agents.
|
|
37
|
+
|
|
38
|
+
>>> stophy = Stophy(os.environ["STOPHY_API_KEY"])
|
|
39
|
+
>>> result = stophy.video(type="transcript", video_url=url)
|
|
40
|
+
>>> print(result["data"]["text"])
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
api_key: str,
|
|
46
|
+
*,
|
|
47
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
48
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
49
|
+
timeout: float = 30.0,
|
|
50
|
+
max_retries: int = 2,
|
|
51
|
+
retry_initial_delay: float = 0.5,
|
|
52
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
if not api_key:
|
|
55
|
+
raise ValueError("Stophy: api_key is required.")
|
|
56
|
+
self._max_retries = max_retries
|
|
57
|
+
self._retry_base = retry_initial_delay
|
|
58
|
+
self.client = httpx.Client(
|
|
59
|
+
base_url=base_url,
|
|
60
|
+
headers={"Authorization": f"Bearer {api_key}", **(headers or {})},
|
|
61
|
+
timeout=timeout,
|
|
62
|
+
transport=transport,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# --- endpoints ---------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
@overload
|
|
68
|
+
def video(
|
|
69
|
+
self,
|
|
70
|
+
*,
|
|
71
|
+
type: Literal["details"],
|
|
72
|
+
video_url: Optional[str] = None,
|
|
73
|
+
sort_by: Optional[str] = None,
|
|
74
|
+
chat_type: Optional[str] = None,
|
|
75
|
+
continuation_token: Optional[str] = None,
|
|
76
|
+
) -> VideoDetailsResponse: ...
|
|
77
|
+
|
|
78
|
+
@overload
|
|
79
|
+
def video(
|
|
80
|
+
self,
|
|
81
|
+
*,
|
|
82
|
+
type: Literal["transcript"],
|
|
83
|
+
video_url: Optional[str] = None,
|
|
84
|
+
sort_by: Optional[str] = None,
|
|
85
|
+
chat_type: Optional[str] = None,
|
|
86
|
+
continuation_token: Optional[str] = None,
|
|
87
|
+
) -> TranscriptResponse: ...
|
|
88
|
+
|
|
89
|
+
@overload
|
|
90
|
+
def video(
|
|
91
|
+
self,
|
|
92
|
+
*,
|
|
93
|
+
type: Literal["comments", "replies"],
|
|
94
|
+
video_url: Optional[str] = None,
|
|
95
|
+
sort_by: Optional[str] = None,
|
|
96
|
+
chat_type: Optional[str] = None,
|
|
97
|
+
continuation_token: Optional[str] = None,
|
|
98
|
+
) -> CommentsResponse: ...
|
|
99
|
+
|
|
100
|
+
@overload
|
|
101
|
+
def video(
|
|
102
|
+
self,
|
|
103
|
+
*,
|
|
104
|
+
type: Literal["livechat"],
|
|
105
|
+
video_url: Optional[str] = None,
|
|
106
|
+
sort_by: Optional[str] = None,
|
|
107
|
+
chat_type: Optional[str] = None,
|
|
108
|
+
continuation_token: Optional[str] = None,
|
|
109
|
+
) -> LiveChatResponse: ...
|
|
110
|
+
|
|
111
|
+
def video(
|
|
112
|
+
self,
|
|
113
|
+
*,
|
|
114
|
+
type: str,
|
|
115
|
+
video_url: Optional[str] = None,
|
|
116
|
+
sort_by: Optional[str] = None,
|
|
117
|
+
chat_type: Optional[str] = None,
|
|
118
|
+
continuation_token: Optional[str] = None,
|
|
119
|
+
) -> VideoResponse:
|
|
120
|
+
"""Video details, transcript, comments, replies, or live chat — pick with ``type``.
|
|
121
|
+
|
|
122
|
+
The shape of ``data`` depends on ``type`` (details / transcript /
|
|
123
|
+
comments / replies / livechat).
|
|
124
|
+
"""
|
|
125
|
+
return self._post(
|
|
126
|
+
"/v1/video",
|
|
127
|
+
{
|
|
128
|
+
"type": type,
|
|
129
|
+
"videoUrl": video_url,
|
|
130
|
+
"sortBy": sort_by,
|
|
131
|
+
"chatType": chat_type,
|
|
132
|
+
"continuationToken": continuation_token,
|
|
133
|
+
},
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def search(
|
|
137
|
+
self,
|
|
138
|
+
*,
|
|
139
|
+
q: str,
|
|
140
|
+
type: Optional[str] = None,
|
|
141
|
+
sort_by: Optional[str] = None,
|
|
142
|
+
upload_date: Optional[str] = None,
|
|
143
|
+
duration: Optional[str] = None,
|
|
144
|
+
features: Optional[List[str]] = None,
|
|
145
|
+
continuation_token: Optional[str] = None,
|
|
146
|
+
) -> SearchResponse:
|
|
147
|
+
"""Search YouTube, optionally filtered by type, sort, date, duration, and features."""
|
|
148
|
+
return self._post(
|
|
149
|
+
"/v1/search",
|
|
150
|
+
{
|
|
151
|
+
"q": q,
|
|
152
|
+
"type": type,
|
|
153
|
+
"sortBy": sort_by,
|
|
154
|
+
"uploadDate": upload_date,
|
|
155
|
+
"duration": duration,
|
|
156
|
+
"features": features,
|
|
157
|
+
"continuationToken": continuation_token,
|
|
158
|
+
},
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def channel(
|
|
162
|
+
self,
|
|
163
|
+
*,
|
|
164
|
+
channel_url: str,
|
|
165
|
+
tab: Optional[str] = None,
|
|
166
|
+
sort_by: Optional[str] = None,
|
|
167
|
+
continuation_token: Optional[str] = None,
|
|
168
|
+
) -> ChannelResponse:
|
|
169
|
+
"""Channel metadata and content. Switch sections with ``tab``."""
|
|
170
|
+
return self._post(
|
|
171
|
+
"/v1/channel",
|
|
172
|
+
{
|
|
173
|
+
"channelUrl": channel_url,
|
|
174
|
+
"tab": tab,
|
|
175
|
+
"sortBy": sort_by,
|
|
176
|
+
"continuationToken": continuation_token,
|
|
177
|
+
},
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def playlist(
|
|
181
|
+
self,
|
|
182
|
+
*,
|
|
183
|
+
playlist_url: str,
|
|
184
|
+
continuation_token: Optional[str] = None,
|
|
185
|
+
) -> PlaylistResponse:
|
|
186
|
+
"""Playlist items. Page through long playlists with ``continuation_token``."""
|
|
187
|
+
return self._post(
|
|
188
|
+
"/v1/playlist",
|
|
189
|
+
{"playlistUrl": playlist_url, "continuationToken": continuation_token},
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def suggest(
|
|
193
|
+
self,
|
|
194
|
+
*,
|
|
195
|
+
q: str,
|
|
196
|
+
hl: Optional[str] = None,
|
|
197
|
+
gl: Optional[str] = None,
|
|
198
|
+
) -> SuggestResponse:
|
|
199
|
+
"""Search autocomplete suggestions."""
|
|
200
|
+
return self._get("/v1/suggest", {"q": q, "hl": hl, "gl": gl})
|
|
201
|
+
|
|
202
|
+
def credits(self) -> CreditsResponse:
|
|
203
|
+
"""Your current credit balance."""
|
|
204
|
+
return self._get("/v1/credits")
|
|
205
|
+
|
|
206
|
+
def logs(
|
|
207
|
+
self,
|
|
208
|
+
*,
|
|
209
|
+
days: Optional[str] = None,
|
|
210
|
+
endpoint: Optional[str] = None,
|
|
211
|
+
page: Optional[int] = None,
|
|
212
|
+
) -> LogsResponse:
|
|
213
|
+
"""Recent API request logs."""
|
|
214
|
+
return self._get("/v1/logs", {"days": days, "endpoint": endpoint, "page": page})
|
|
215
|
+
|
|
216
|
+
def usage(
|
|
217
|
+
self,
|
|
218
|
+
*,
|
|
219
|
+
days: Optional[str] = None,
|
|
220
|
+
tz: Optional[str] = None,
|
|
221
|
+
) -> UsageResponse:
|
|
222
|
+
"""Daily credit and request counts."""
|
|
223
|
+
return self._get("/v1/usage", {"days": days, "tz": tz})
|
|
224
|
+
|
|
225
|
+
# --- plumbing ----------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
def _get(self, path: str, params: Optional[Mapping[str, Any]] = None) -> Any:
|
|
228
|
+
return self._handle(self._send("GET", path, params=_compact(params or {})))
|
|
229
|
+
|
|
230
|
+
def _post(self, path: str, body: Mapping[str, Any]) -> Any:
|
|
231
|
+
return self._handle(self._send("POST", path, json=_compact(body)))
|
|
232
|
+
|
|
233
|
+
def _send(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
|
234
|
+
"""Send a request, retrying transient failures with backoff + jitter.
|
|
235
|
+
|
|
236
|
+
Every Stophy endpoint is a read, so retrying is always safe.
|
|
237
|
+
"""
|
|
238
|
+
attempt = 0
|
|
239
|
+
while True:
|
|
240
|
+
try:
|
|
241
|
+
resp = self.client.request(method, path, **kwargs)
|
|
242
|
+
except httpx.TransportError:
|
|
243
|
+
if attempt >= self._max_retries:
|
|
244
|
+
raise
|
|
245
|
+
time.sleep(self._backoff(attempt))
|
|
246
|
+
attempt += 1
|
|
247
|
+
continue
|
|
248
|
+
if attempt < self._max_retries and resp.status_code in RETRYABLE_STATUS:
|
|
249
|
+
time.sleep(self._retry_after(resp, attempt))
|
|
250
|
+
attempt += 1
|
|
251
|
+
continue
|
|
252
|
+
return resp
|
|
253
|
+
|
|
254
|
+
def _backoff(self, attempt: int) -> float:
|
|
255
|
+
window = self._retry_base * (2**attempt)
|
|
256
|
+
return window / 2 + random.random() * (window / 2)
|
|
257
|
+
|
|
258
|
+
def _retry_after(self, resp: httpx.Response, attempt: int) -> float:
|
|
259
|
+
header = resp.headers.get("retry-after")
|
|
260
|
+
if header:
|
|
261
|
+
try:
|
|
262
|
+
return float(header)
|
|
263
|
+
except ValueError:
|
|
264
|
+
try:
|
|
265
|
+
delay = parsedate_to_datetime(header).timestamp() - time.time()
|
|
266
|
+
return max(0.0, delay)
|
|
267
|
+
except (TypeError, ValueError):
|
|
268
|
+
pass
|
|
269
|
+
return self._backoff(attempt)
|
|
270
|
+
|
|
271
|
+
def _handle(self, resp: httpx.Response) -> Dict[str, Any]:
|
|
272
|
+
try:
|
|
273
|
+
payload = resp.json()
|
|
274
|
+
except ValueError:
|
|
275
|
+
payload = None
|
|
276
|
+
|
|
277
|
+
if resp.is_success and isinstance(payload, dict):
|
|
278
|
+
return payload
|
|
279
|
+
|
|
280
|
+
err = payload if isinstance(payload, dict) else {}
|
|
281
|
+
raise StophyError(
|
|
282
|
+
err.get("error") or f"Stophy request failed with status {resp.status_code}",
|
|
283
|
+
status=resp.status_code,
|
|
284
|
+
code=err.get("code"),
|
|
285
|
+
request_id=resp.headers.get("x-request-id"),
|
|
286
|
+
details=err.get("details"),
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
# --- lifecycle ---------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
def close(self) -> None:
|
|
292
|
+
self.client.close()
|
|
293
|
+
|
|
294
|
+
def __enter__(self) -> "Stophy":
|
|
295
|
+
return self
|
|
296
|
+
|
|
297
|
+
def __exit__(self, *exc: Any) -> None:
|
|
298
|
+
self.close()
|
stophy/errors.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class StophyError(Exception):
|
|
7
|
+
"""Raised when the Stophy API responds with a non-2xx status."""
|
|
8
|
+
|
|
9
|
+
def __init__(
|
|
10
|
+
self,
|
|
11
|
+
message: str,
|
|
12
|
+
*,
|
|
13
|
+
status: int,
|
|
14
|
+
code: Optional[str] = None,
|
|
15
|
+
request_id: Optional[str] = None,
|
|
16
|
+
details: Any = None,
|
|
17
|
+
) -> None:
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.status = status
|
|
20
|
+
self.code = code
|
|
21
|
+
self.request_id = request_id
|
|
22
|
+
self.details = details
|
stophy/types.py
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"""Response types mirroring the Stophy OpenAPI schemas.
|
|
2
|
+
|
|
3
|
+
These are ``TypedDict``s with ``total=False`` — they describe the shape of the
|
|
4
|
+
JSON the API returns so editors and type checkers can help, without forcing
|
|
5
|
+
every optional/nullable field to be present.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import List, Literal, Optional, TypedDict, Union
|
|
11
|
+
|
|
12
|
+
CacheState = Literal["hit", "miss"]
|
|
13
|
+
ErrorCode = Literal[
|
|
14
|
+
"UNAUTHORIZED",
|
|
15
|
+
"INSUFFICIENT_CREDITS",
|
|
16
|
+
"BAD_REQUEST",
|
|
17
|
+
"INVALID_INPUT",
|
|
18
|
+
"NOT_FOUND",
|
|
19
|
+
"CONCURRENCY_LIMITED",
|
|
20
|
+
"INTERNAL_ERROR",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Thumbnail(TypedDict, total=False):
|
|
25
|
+
url: str
|
|
26
|
+
width: int
|
|
27
|
+
height: int
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class EmptyState(TypedDict, total=False):
|
|
31
|
+
code: str
|
|
32
|
+
message: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# --- transcript -----------------------------------------------------------
|
|
36
|
+
class TranscriptLanguage(TypedDict, total=False):
|
|
37
|
+
code: Optional[str]
|
|
38
|
+
name: Optional[str]
|
|
39
|
+
isAutoGenerated: bool
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TranscriptSegment(TypedDict, total=False):
|
|
43
|
+
start: float
|
|
44
|
+
duration: float
|
|
45
|
+
text: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TranscriptResult(TypedDict, total=False):
|
|
49
|
+
language: TranscriptLanguage
|
|
50
|
+
segments: List[TranscriptSegment]
|
|
51
|
+
text: str
|
|
52
|
+
empty: EmptyState
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# --- video details --------------------------------------------------------
|
|
56
|
+
class VideoDetails(TypedDict, total=False):
|
|
57
|
+
id: str
|
|
58
|
+
type: Literal["video"]
|
|
59
|
+
videoUrl: str
|
|
60
|
+
title: Optional[str]
|
|
61
|
+
author: Optional[str]
|
|
62
|
+
authorId: Optional[str]
|
|
63
|
+
category: Optional[str]
|
|
64
|
+
description: Optional[str]
|
|
65
|
+
durationSec: Optional[float]
|
|
66
|
+
durationText: Optional[str]
|
|
67
|
+
isLive: bool
|
|
68
|
+
likeCount: Optional[float]
|
|
69
|
+
likeCountText: Optional[str]
|
|
70
|
+
publishedAt: Optional[str]
|
|
71
|
+
tags: List[str]
|
|
72
|
+
viewCount: Optional[float]
|
|
73
|
+
viewCountText: Optional[str]
|
|
74
|
+
thumbnails: List[Thumbnail]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class RelatedVideo(TypedDict, total=False):
|
|
78
|
+
id: str
|
|
79
|
+
type: Literal["video"]
|
|
80
|
+
videoUrl: str
|
|
81
|
+
title: Optional[str]
|
|
82
|
+
author: Optional[str]
|
|
83
|
+
authorId: Optional[str]
|
|
84
|
+
durationSec: Optional[float]
|
|
85
|
+
durationText: Optional[str]
|
|
86
|
+
publishedAt: Optional[str]
|
|
87
|
+
publishedAtText: Optional[str]
|
|
88
|
+
viewCount: Optional[float]
|
|
89
|
+
viewCountText: Optional[str]
|
|
90
|
+
thumbnails: List[Thumbnail]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class VideoDetailsData(TypedDict, total=False):
|
|
94
|
+
video: VideoDetails
|
|
95
|
+
related: List[RelatedVideo]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# --- comments / replies ---------------------------------------------------
|
|
99
|
+
class Comment(TypedDict, total=False):
|
|
100
|
+
id: Optional[str]
|
|
101
|
+
text: Optional[str]
|
|
102
|
+
author: Optional[str]
|
|
103
|
+
authorId: Optional[str]
|
|
104
|
+
authorThumbnail: Optional[str]
|
|
105
|
+
hasChannelOwnerReplied: bool
|
|
106
|
+
isChannelOwner: bool
|
|
107
|
+
isHearted: bool
|
|
108
|
+
isPinned: bool
|
|
109
|
+
isVerified: bool
|
|
110
|
+
publishedAt: Optional[str]
|
|
111
|
+
publishedAtText: Optional[str]
|
|
112
|
+
likeCount: Optional[float]
|
|
113
|
+
likeCountText: Optional[str]
|
|
114
|
+
replyCount: Optional[float]
|
|
115
|
+
replyCountText: Optional[str]
|
|
116
|
+
repliesToken: Optional[str]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class CommentsData(TypedDict, total=False):
|
|
120
|
+
items: List[Comment]
|
|
121
|
+
continuationToken: Optional[str]
|
|
122
|
+
empty: EmptyState
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# --- live chat ------------------------------------------------------------
|
|
126
|
+
class LiveChatMessage(TypedDict, total=False):
|
|
127
|
+
id: str
|
|
128
|
+
text: str
|
|
129
|
+
author: Optional[str]
|
|
130
|
+
authorId: Optional[str]
|
|
131
|
+
timestampUsec: Optional[str]
|
|
132
|
+
isOwner: bool
|
|
133
|
+
isModerator: bool
|
|
134
|
+
isVerified: bool
|
|
135
|
+
superChatAmount: Optional[str]
|
|
136
|
+
superChatCurrency: Optional[str]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class LiveChatData(TypedDict, total=False):
|
|
140
|
+
status: str
|
|
141
|
+
isLive: bool
|
|
142
|
+
concurrentViewers: Optional[float]
|
|
143
|
+
pollIntervalMs: Optional[float]
|
|
144
|
+
messages: List[LiveChatMessage]
|
|
145
|
+
continuationToken: Optional[str]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
VideoData = Union[VideoDetailsData, TranscriptResult, CommentsData, LiveChatData]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# --- search ---------------------------------------------------------------
|
|
152
|
+
class SearchVideo(TypedDict, total=False):
|
|
153
|
+
type: Literal["video"]
|
|
154
|
+
id: str
|
|
155
|
+
videoUrl: str
|
|
156
|
+
title: str
|
|
157
|
+
author: Optional[str]
|
|
158
|
+
authorId: Optional[str]
|
|
159
|
+
description: Optional[str]
|
|
160
|
+
duration: Optional[str]
|
|
161
|
+
durationSec: Optional[float]
|
|
162
|
+
durationText: Optional[str]
|
|
163
|
+
isLive: bool
|
|
164
|
+
isUpcoming: bool
|
|
165
|
+
isVerified: bool
|
|
166
|
+
viewCount: Optional[float]
|
|
167
|
+
viewCountText: Optional[str]
|
|
168
|
+
publishedAt: Optional[str]
|
|
169
|
+
publishedAtText: Optional[str]
|
|
170
|
+
thumbnails: List[Thumbnail]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class SearchShort(TypedDict, total=False):
|
|
174
|
+
type: Literal["short"]
|
|
175
|
+
id: str
|
|
176
|
+
shortUrl: str
|
|
177
|
+
title: str
|
|
178
|
+
author: Optional[str]
|
|
179
|
+
authorId: Optional[str]
|
|
180
|
+
description: Optional[str]
|
|
181
|
+
duration: Optional[str]
|
|
182
|
+
durationSec: Optional[float]
|
|
183
|
+
durationText: Optional[str]
|
|
184
|
+
viewCount: Optional[float]
|
|
185
|
+
viewCountText: Optional[str]
|
|
186
|
+
publishedAt: Optional[str]
|
|
187
|
+
publishedAtText: Optional[str]
|
|
188
|
+
thumbnails: List[Thumbnail]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class SearchPlaylist(TypedDict, total=False):
|
|
192
|
+
type: Literal["playlist"]
|
|
193
|
+
id: str
|
|
194
|
+
playlistUrl: str
|
|
195
|
+
title: str
|
|
196
|
+
author: Optional[str]
|
|
197
|
+
authorId: Optional[str]
|
|
198
|
+
videoCount: Optional[float]
|
|
199
|
+
videoCountText: Optional[str]
|
|
200
|
+
thumbnails: List[Thumbnail]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class SearchChannel(TypedDict, total=False):
|
|
204
|
+
type: Literal["channel"]
|
|
205
|
+
id: str
|
|
206
|
+
channelUrl: str
|
|
207
|
+
name: str
|
|
208
|
+
handle: Optional[str]
|
|
209
|
+
description: Optional[str]
|
|
210
|
+
subscriberCount: Optional[float]
|
|
211
|
+
subscriberCountText: Optional[str]
|
|
212
|
+
isVerified: bool
|
|
213
|
+
thumbnails: List[Thumbnail]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
SearchItem = Union[SearchVideo, SearchShort, SearchPlaylist, SearchChannel]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class SearchData(TypedDict, total=False):
|
|
220
|
+
items: List[SearchItem]
|
|
221
|
+
continuationToken: Optional[str]
|
|
222
|
+
estimatedResults: int
|
|
223
|
+
empty: EmptyState
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# --- channel --------------------------------------------------------------
|
|
227
|
+
class ChannelLink(TypedDict, total=False):
|
|
228
|
+
title: str
|
|
229
|
+
url: str
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class ChannelProfile(TypedDict, total=False):
|
|
233
|
+
id: Optional[str]
|
|
234
|
+
name: Optional[str]
|
|
235
|
+
handle: Optional[str]
|
|
236
|
+
channelUrl: Optional[str]
|
|
237
|
+
description: Optional[str]
|
|
238
|
+
subscriberCount: Optional[float]
|
|
239
|
+
subscriberCountText: Optional[str]
|
|
240
|
+
videoCount: Optional[float]
|
|
241
|
+
videoCountText: Optional[str]
|
|
242
|
+
viewCount: Optional[float]
|
|
243
|
+
viewCountText: Optional[str]
|
|
244
|
+
isVerified: bool
|
|
245
|
+
country: Optional[str]
|
|
246
|
+
joinedDate: Optional[str]
|
|
247
|
+
thumbnails: List[Thumbnail]
|
|
248
|
+
banners: List[Thumbnail]
|
|
249
|
+
links: Optional[List[ChannelLink]]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class ContentItem(TypedDict, total=False):
|
|
253
|
+
id: str
|
|
254
|
+
type: str
|
|
255
|
+
title: Optional[str]
|
|
256
|
+
videoUrl: str
|
|
257
|
+
shortUrl: str
|
|
258
|
+
playlistUrl: str
|
|
259
|
+
author: Optional[str]
|
|
260
|
+
authorId: Optional[str]
|
|
261
|
+
durationSec: Optional[float]
|
|
262
|
+
durationText: Optional[str]
|
|
263
|
+
viewCount: Optional[float]
|
|
264
|
+
viewCountText: Optional[str]
|
|
265
|
+
publishedAt: Optional[str]
|
|
266
|
+
publishedAtText: Optional[str]
|
|
267
|
+
thumbnails: List[Thumbnail]
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class ChannelData(TypedDict, total=False):
|
|
271
|
+
channel: Optional[ChannelProfile]
|
|
272
|
+
tab: Optional[str]
|
|
273
|
+
items: List[ContentItem]
|
|
274
|
+
continuationToken: Optional[str]
|
|
275
|
+
empty: EmptyState
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# --- playlist -------------------------------------------------------------
|
|
279
|
+
class PlaylistMeta(TypedDict, total=False):
|
|
280
|
+
id: str
|
|
281
|
+
type: Literal["playlist"]
|
|
282
|
+
playlistUrl: str
|
|
283
|
+
title: Optional[str]
|
|
284
|
+
author: Optional[str]
|
|
285
|
+
authorId: Optional[str]
|
|
286
|
+
description: Optional[str]
|
|
287
|
+
videoCount: Optional[str]
|
|
288
|
+
thumbnails: List[Thumbnail]
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class PlaylistItem(TypedDict, total=False):
|
|
292
|
+
id: str
|
|
293
|
+
type: Literal["video"]
|
|
294
|
+
videoUrl: str
|
|
295
|
+
title: Optional[str]
|
|
296
|
+
author: Optional[str]
|
|
297
|
+
authorId: Optional[str]
|
|
298
|
+
durationSec: Optional[float]
|
|
299
|
+
durationText: Optional[str]
|
|
300
|
+
index: Optional[float]
|
|
301
|
+
isLive: bool
|
|
302
|
+
isPlayable: bool
|
|
303
|
+
isUpcoming: bool
|
|
304
|
+
upcomingAt: Optional[str]
|
|
305
|
+
viewCount: Optional[float]
|
|
306
|
+
viewCountText: Optional[str]
|
|
307
|
+
publishedAt: Optional[str]
|
|
308
|
+
publishedAtText: Optional[str]
|
|
309
|
+
thumbnails: List[Thumbnail]
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class PlaylistData(TypedDict, total=False):
|
|
313
|
+
playlist: Optional[PlaylistMeta]
|
|
314
|
+
items: List[PlaylistItem]
|
|
315
|
+
continuationToken: Optional[str]
|
|
316
|
+
empty: EmptyState
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
# --- suggest / account ----------------------------------------------------
|
|
320
|
+
class SuggestData(TypedDict, total=False):
|
|
321
|
+
q: str
|
|
322
|
+
hl: str
|
|
323
|
+
gl: str
|
|
324
|
+
suggestions: List[str]
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class CreditsData(TypedDict, total=False):
|
|
328
|
+
credits: int
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class LogEntry(TypedDict, total=False):
|
|
332
|
+
id: str
|
|
333
|
+
userId: str
|
|
334
|
+
apiKeyId: Optional[str]
|
|
335
|
+
apiKeyName: Optional[str]
|
|
336
|
+
endpoint: str
|
|
337
|
+
method: str
|
|
338
|
+
status: int
|
|
339
|
+
credits: int
|
|
340
|
+
durationMs: Optional[float]
|
|
341
|
+
response: Optional[str]
|
|
342
|
+
createdAt: str
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
class LogsData(TypedDict, total=False):
|
|
346
|
+
logs: List[LogEntry]
|
|
347
|
+
total: int
|
|
348
|
+
page: int
|
|
349
|
+
pageSize: int
|
|
350
|
+
totalPages: int
|
|
351
|
+
endpoints: List[str]
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
class UsageItem(TypedDict, total=False):
|
|
355
|
+
date: str
|
|
356
|
+
credits: int
|
|
357
|
+
requests: int
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
class UsageData(TypedDict, total=False):
|
|
361
|
+
items: List[UsageItem]
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# --- response envelopes ---------------------------------------------------
|
|
365
|
+
class _Envelope(TypedDict, total=False):
|
|
366
|
+
success: bool
|
|
367
|
+
requestId: str
|
|
368
|
+
cacheState: CacheState
|
|
369
|
+
creditsUsed: int
|
|
370
|
+
creditsRemaining: int
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
class _AccountEnvelope(TypedDict, total=False):
|
|
374
|
+
success: bool
|
|
375
|
+
requestId: str
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
class VideoResponse(_Envelope, total=False):
|
|
379
|
+
data: VideoData
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
class VideoDetailsResponse(_Envelope, total=False):
|
|
383
|
+
data: VideoDetailsData
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
class TranscriptResponse(_Envelope, total=False):
|
|
387
|
+
data: TranscriptResult
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
class CommentsResponse(_Envelope, total=False):
|
|
391
|
+
data: CommentsData
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
class LiveChatResponse(_Envelope, total=False):
|
|
395
|
+
data: LiveChatData
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
class SearchResponse(_Envelope, total=False):
|
|
399
|
+
data: SearchData
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
class ChannelResponse(_Envelope, total=False):
|
|
403
|
+
data: ChannelData
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
class PlaylistResponse(_Envelope, total=False):
|
|
407
|
+
data: PlaylistData
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
class SuggestResponse(_Envelope, total=False):
|
|
411
|
+
data: SuggestData
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
class CreditsResponse(_AccountEnvelope, total=False):
|
|
415
|
+
data: CreditsData
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
class LogsResponse(_AccountEnvelope, total=False):
|
|
419
|
+
data: LogsData
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
class UsageResponse(_AccountEnvelope, total=False):
|
|
423
|
+
data: UsageData
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: stophy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for Stophy - YouTube context API for AI agents.
|
|
5
|
+
Project-URL: Homepage, https://stophy.dev
|
|
6
|
+
Project-URL: Repository, https://github.com/stophy/stophy-sdk
|
|
7
|
+
Author: Stophy
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: ai-agents,sdk,stophy,transcript,youtube
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Requires-Dist: httpx>=0.27
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# stophy
|
|
17
|
+
|
|
18
|
+
Official Python SDK for [Stophy](https://stophy.dev) **YouTube context API for AI agents**. Search videos, fetch transcripts, read comments and live chat, inspect channels and playlists, and get autocomplete suggestions, all returned as structured JSON.
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
uv install stophy
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Get an API key from your [Stophy dashboard](https://stophy.dev). The SDK sends it as `Authorization: Bearer <key>` on every request.
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
import os
|
|
33
|
+
from stophy import Stophy
|
|
34
|
+
|
|
35
|
+
stophy = Stophy(os.environ["STOPHY_API_KEY"])
|
|
36
|
+
|
|
37
|
+
result = stophy.video(type="transcript", video_url="https://www.youtube.com/watch?v=D7liwdjvhWc")
|
|
38
|
+
print(result["data"]["text"])
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Methods
|
|
42
|
+
|
|
43
|
+
| Method | Description |
|
|
44
|
+
| --- | --- |
|
|
45
|
+
| `stophy.video(...)` | Details, transcript, comments, replies, or live chat (set `type`) |
|
|
46
|
+
| `stophy.search(...)` | Search with filters for type, sort, date, duration, features |
|
|
47
|
+
| `stophy.channel(...)` | Channel metadata + content by `tab` |
|
|
48
|
+
| `stophy.playlist(...)` | Playlist items, paginated |
|
|
49
|
+
| `stophy.suggest(...)` | Search autocomplete suggestions |
|
|
50
|
+
| `stophy.credits()` | Current credit balance |
|
|
51
|
+
| `stophy.logs(...)` | Recent request logs |
|
|
52
|
+
| `stophy.usage(...)` | Daily credit/request counts |
|
|
53
|
+
|
|
54
|
+
Arguments are keyword-only and snake_case; the SDK maps them to the API's field names for you. `video()` is overloaded on `type`, so the returned `data` is typed for the variant you asked for (transcript, comments, details, …).
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
# Search
|
|
58
|
+
results = stophy.search(q="typescript tutorial", sort_by="popularity", duration="long")
|
|
59
|
+
|
|
60
|
+
# Comments, then replies to a comment
|
|
61
|
+
comments = stophy.video(type="comments", video_url=url, sort_by="top")
|
|
62
|
+
token = comments["data"]["items"][0].get("repliesToken")
|
|
63
|
+
if token:
|
|
64
|
+
replies = stophy.video(type="replies", continuation_token=token)
|
|
65
|
+
|
|
66
|
+
# Autocomplete and account
|
|
67
|
+
print(stophy.suggest(q="react")["data"]["suggestions"])
|
|
68
|
+
print(stophy.credits()["data"]["credits"])
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Pagination
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
token = None
|
|
75
|
+
while True:
|
|
76
|
+
page = stophy.search(q="lofi", continuation_token=token)
|
|
77
|
+
... # handle page["data"]["items"]
|
|
78
|
+
token = page["data"].get("continuationToken")
|
|
79
|
+
if not token:
|
|
80
|
+
break
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Errors
|
|
84
|
+
|
|
85
|
+
Non-2xx responses raise `StophyError`:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from stophy import Stophy, StophyError
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
stophy.credits()
|
|
92
|
+
except StophyError as err:
|
|
93
|
+
print(err.status, err.code, err, err.request_id)
|
|
94
|
+
# err.code: "UNAUTHORIZED" | "INSUFFICIENT_CREDITS" | "BAD_REQUEST" |
|
|
95
|
+
# "INVALID_INPUT" | "NOT_FOUND" | "CONCURRENCY_LIMITED" | "INTERNAL_ERROR"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
MIT
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
stophy/__init__.py,sha256=XLWN31DbMafXDwdMdZeSA0Y7GktWdt7jxrknHAqrDgY,147
|
|
2
|
+
stophy/client.py,sha256=nOPzLUxeL1ktjeAI_8wDv57y4G_yOD3NlZG5Idu5YuY,9167
|
|
3
|
+
stophy/errors.py,sha256=G4JQWQJ8MZcG8JMFGGxzoHJDuMjkOlxLpDJWRBghd-Y,535
|
|
4
|
+
stophy/types.py,sha256=tbGEbcV3S0nZSvFChSk-fFWlaj_c_NTLvGChZhAayRw,10046
|
|
5
|
+
stophy-0.1.0.dist-info/METADATA,sha256=xc9HCfHYBc0aZalBHc0qL3MyNsYCshVytqqWGSJyack,3051
|
|
6
|
+
stophy-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
7
|
+
stophy-0.1.0.dist-info/RECORD,,
|