ellements 0.2.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.
- ellements/__init__.py +57 -0
- ellements/agents/__init__.py +45 -0
- ellements/agents/backend.py +100 -0
- ellements/agents/builder.py +303 -0
- ellements/agents/claude_backend.py +200 -0
- ellements/agents/controller.py +237 -0
- ellements/agents/openai_backend.py +187 -0
- ellements/agents/py.typed +0 -0
- ellements/agents/runner.py +358 -0
- ellements/agents/tools.py +30 -0
- ellements/benchmarking/__init__.py +52 -0
- ellements/benchmarking/harness.py +342 -0
- ellements/benchmarking/py.typed +0 -0
- ellements/benchmarking/results.py +173 -0
- ellements/cli/__init__.py +80 -0
- ellements/cli/adapters.py +73 -0
- ellements/cli/agent_tui.py +1178 -0
- ellements/cli/components.py +411 -0
- ellements/cli/printer.py +229 -0
- ellements/cli/py.typed +0 -0
- ellements/core/__init__.py +112 -0
- ellements/core/async_utils.py +42 -0
- ellements/core/budgeting/__init__.py +43 -0
- ellements/core/budgeting/client.py +276 -0
- ellements/core/budgeting/protocol.py +51 -0
- ellements/core/budgeting/trackers.py +177 -0
- ellements/core/caching/__init__.py +51 -0
- ellements/core/caching/cache.py +73 -0
- ellements/core/caching/client.py +300 -0
- ellements/core/caching/disk.py +128 -0
- ellements/core/caching/keys.py +97 -0
- ellements/core/caching/memory.py +104 -0
- ellements/core/chunking.py +262 -0
- ellements/core/config.py +180 -0
- ellements/core/exceptions.py +145 -0
- ellements/core/llm/__init__.py +46 -0
- ellements/core/llm/client.py +1124 -0
- ellements/core/llm/images.py +226 -0
- ellements/core/llm/messages.py +202 -0
- ellements/core/llm/model_params.py +66 -0
- ellements/core/llm/protocol.py +146 -0
- ellements/core/llm/requests.py +100 -0
- ellements/core/llm/structured.py +91 -0
- ellements/core/llm/wrapper.py +79 -0
- ellements/core/observability/__init__.py +39 -0
- ellements/core/observability/events.py +169 -0
- ellements/core/observability/jsonl_logger.py +244 -0
- ellements/core/observability/markdown_formatter.py +197 -0
- ellements/core/observability/observer.py +56 -0
- ellements/core/prompting/__init__.py +14 -0
- ellements/core/prompting/context.py +185 -0
- ellements/core/prompting/guideline.py +133 -0
- ellements/core/prompting/persona.py +267 -0
- ellements/core/prompting/sources.py +92 -0
- ellements/core/py.typed +0 -0
- ellements/core/rate_limit/__init__.py +44 -0
- ellements/core/rate_limit/bucket.py +85 -0
- ellements/core/rate_limit/client.py +216 -0
- ellements/core/rate_limit/protocol.py +27 -0
- ellements/core/templating.py +126 -0
- ellements/core/tools/__init__.py +51 -0
- ellements/core/tools/dialects.py +136 -0
- ellements/core/tools/executor.py +48 -0
- ellements/core/tools/protocol.py +36 -0
- ellements/core/tools/records.py +28 -0
- ellements/core/tools/registry.py +205 -0
- ellements/core/tools/schemas.py +80 -0
- ellements/core/tools/simple.py +119 -0
- ellements/core/tools/spec.py +33 -0
- ellements/domain_specific/__init__.py +7 -0
- ellements/domain_specific/finance/__init__.py +188 -0
- ellements/domain_specific/finance/calculations.py +837 -0
- ellements/domain_specific/finance/charts.py +426 -0
- ellements/domain_specific/finance/portfolio.py +129 -0
- ellements/domain_specific/finance/quant_analysis.py +279 -0
- ellements/domain_specific/finance/risk.py +362 -0
- ellements/domain_specific/finance/technical_indicators.py +241 -0
- ellements/domain_specific/finance/tools.py +483 -0
- ellements/domain_specific/finance/valuation.py +312 -0
- ellements/domain_specific/finance/yahoo_finance.py +1523 -0
- ellements/domain_specific/finance/yahoo_finance_models.py +321 -0
- ellements/domain_specific/py.typed +0 -0
- ellements/execution/__init__.py +56 -0
- ellements/execution/callbacks.py +149 -0
- ellements/execution/catalog.py +70 -0
- ellements/execution/collaborative.py +191 -0
- ellements/execution/config.py +135 -0
- ellements/execution/py.typed +0 -0
- ellements/execution/reflection.py +156 -0
- ellements/execution/self_consistency.py +189 -0
- ellements/execution/single_call.py +48 -0
- ellements/execution/strategies.py +237 -0
- ellements/execution/tree_of_thought.py +541 -0
- ellements/fslm/__init__.py +108 -0
- ellements/fslm/builtins.py +103 -0
- ellements/fslm/cli.py +199 -0
- ellements/fslm/context.py +50 -0
- ellements/fslm/definition.py +163 -0
- ellements/fslm/det.py +173 -0
- ellements/fslm/dsl.py +458 -0
- ellements/fslm/errors.py +38 -0
- ellements/fslm/evaluators.py +141 -0
- ellements/fslm/kernel.py +603 -0
- ellements/fslm/loading.py +123 -0
- ellements/fslm/models.py +623 -0
- ellements/fslm/nl.py +87 -0
- ellements/fslm/observers.py +107 -0
- ellements/fslm/persistence.py +72 -0
- ellements/fslm/py.typed +0 -0
- ellements/fslm/rendering.py +551 -0
- ellements/fslm/visualization.py +25 -0
- ellements/reporting/__init__.py +12 -0
- ellements/reporting/charts.py +364 -0
- ellements/reporting/html_generation.py +132 -0
- ellements/reporting/py.typed +0 -0
- ellements/reporting/visualization.py +73 -0
- ellements/standard_tools/__init__.py +22 -0
- ellements/standard_tools/py.typed +0 -0
- ellements/standard_tools/terminal.py +205 -0
- ellements/standard_tools/web/__init__.py +37 -0
- ellements/standard_tools/web/browser_viewer.py +214 -0
- ellements/standard_tools/web/crawler.py +454 -0
- ellements/standard_tools/web/search.py +399 -0
- ellements/standard_tools/web/youtube.py +1297 -0
- ellements-0.2.0.dist-info/METADATA +368 -0
- ellements-0.2.0.dist-info/RECORD +130 -0
- ellements-0.2.0.dist-info/WHEEL +5 -0
- ellements-0.2.0.dist-info/entry_points.txt +2 -0
- ellements-0.2.0.dist-info/licenses/LICENSE +42 -0
- ellements-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1297 @@
|
|
|
1
|
+
"""YouTube search and transcript functionality."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
from typing import Any, Literal, cast
|
|
11
|
+
from urllib.parse import parse_qs, urlparse
|
|
12
|
+
|
|
13
|
+
from ellements.core import ToolRegistry
|
|
14
|
+
from pydantic import BaseModel, Field
|
|
15
|
+
from youtube_transcript_api import YouTubeTranscriptApi
|
|
16
|
+
|
|
17
|
+
# Fixes for youtube-search-python compatibility issues:
|
|
18
|
+
# - The library uses the old 'proxies' parameter which httpx no longer supports.
|
|
19
|
+
# - Some YouTube search results omit channel browse IDs, but the library assumes
|
|
20
|
+
# they are always present and crashes while building result objects.
|
|
21
|
+
try:
|
|
22
|
+
import httpx
|
|
23
|
+
from youtubesearchpython.core.constants import userAgent
|
|
24
|
+
from youtubesearchpython.core.requests import RequestCore
|
|
25
|
+
from youtubesearchpython.handlers.componenthandler import (
|
|
26
|
+
ComponentHandler,
|
|
27
|
+
videoElementKey,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Store original methods
|
|
31
|
+
_original_sync_post = RequestCore.syncPostRequest
|
|
32
|
+
_original_sync_get = RequestCore.syncGetRequest
|
|
33
|
+
|
|
34
|
+
def _safe_youtube_identifier(value: Any) -> str:
|
|
35
|
+
"""Return a search result identifier only when it is a non-empty string."""
|
|
36
|
+
return value if isinstance(value, str) and value else ""
|
|
37
|
+
|
|
38
|
+
def _build_youtube_link(prefix: str, identifier: Any) -> str:
|
|
39
|
+
"""Build a YouTube link without crashing on missing identifiers."""
|
|
40
|
+
safe_identifier = _safe_youtube_identifier(identifier)
|
|
41
|
+
return f"{prefix}{safe_identifier}" if safe_identifier else ""
|
|
42
|
+
|
|
43
|
+
# Create patched versions that work with httpx
|
|
44
|
+
def _patched_sync_post(self: Any) -> httpx.Response:
|
|
45
|
+
"""Patched syncPostRequest that works with httpx."""
|
|
46
|
+
if self.proxy:
|
|
47
|
+
# Use Client with proxy
|
|
48
|
+
with httpx.Client(proxy=self.proxy) as client:
|
|
49
|
+
return client.post(
|
|
50
|
+
self.url,
|
|
51
|
+
headers={"User-Agent": userAgent},
|
|
52
|
+
json=self.data,
|
|
53
|
+
timeout=self.timeout
|
|
54
|
+
)
|
|
55
|
+
else:
|
|
56
|
+
# No proxy, use direct call
|
|
57
|
+
return httpx.post(
|
|
58
|
+
self.url,
|
|
59
|
+
headers={"User-Agent": userAgent},
|
|
60
|
+
json=self.data,
|
|
61
|
+
timeout=self.timeout
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def _patched_sync_get(self: Any) -> httpx.Response:
|
|
65
|
+
"""Patched syncGetRequest that works with httpx."""
|
|
66
|
+
if self.proxy:
|
|
67
|
+
# Use Client with proxy
|
|
68
|
+
with httpx.Client(proxy=self.proxy) as client:
|
|
69
|
+
return client.get(
|
|
70
|
+
self.url,
|
|
71
|
+
headers={"User-Agent": userAgent},
|
|
72
|
+
timeout=self.timeout,
|
|
73
|
+
cookies={'CONSENT': 'YES+1'}
|
|
74
|
+
)
|
|
75
|
+
else:
|
|
76
|
+
# No proxy, use direct call
|
|
77
|
+
return httpx.get(
|
|
78
|
+
self.url,
|
|
79
|
+
headers={"User-Agent": userAgent},
|
|
80
|
+
timeout=self.timeout,
|
|
81
|
+
cookies={'CONSENT': 'YES+1'}
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def _patched_get_video_component(
|
|
85
|
+
self: ComponentHandler,
|
|
86
|
+
element: dict[str, Any],
|
|
87
|
+
shelfTitle: str | None = None,
|
|
88
|
+
) -> dict[str, Any]:
|
|
89
|
+
"""Patched video component builder that tolerates missing channel IDs."""
|
|
90
|
+
video = element[videoElementKey]
|
|
91
|
+
video_id = _safe_youtube_identifier(self._getValue(video, ['videoId']))
|
|
92
|
+
channel_id = _safe_youtube_identifier(
|
|
93
|
+
self._getValue(
|
|
94
|
+
video,
|
|
95
|
+
['ownerText', 'runs', 0, 'navigationEndpoint', 'browseEndpoint', 'browseId'],
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
component = {
|
|
100
|
+
'type': 'video',
|
|
101
|
+
'id': video_id,
|
|
102
|
+
'title': self._getValue(video, ['title', 'runs', 0, 'text']),
|
|
103
|
+
'publishedTime': self._getValue(video, ['publishedTimeText', 'simpleText']),
|
|
104
|
+
'duration': self._getValue(video, ['lengthText', 'simpleText']),
|
|
105
|
+
'viewCount': {
|
|
106
|
+
'text': self._getValue(video, ['viewCountText', 'simpleText']),
|
|
107
|
+
'short': self._getValue(video, ['shortViewCountText', 'simpleText']),
|
|
108
|
+
},
|
|
109
|
+
'thumbnails': self._getValue(video, ['thumbnail', 'thumbnails']),
|
|
110
|
+
'richThumbnail': self._getValue(
|
|
111
|
+
video,
|
|
112
|
+
['richThumbnail', 'movingThumbnailRenderer', 'movingThumbnailDetails', 'thumbnails', 0],
|
|
113
|
+
),
|
|
114
|
+
'descriptionSnippet': self._getValue(
|
|
115
|
+
video,
|
|
116
|
+
['detailedMetadataSnippets', 0, 'snippetText', 'runs'],
|
|
117
|
+
),
|
|
118
|
+
'channel': {
|
|
119
|
+
'name': self._getValue(video, ['ownerText', 'runs', 0, 'text']),
|
|
120
|
+
'id': channel_id,
|
|
121
|
+
'thumbnails': self._getValue(
|
|
122
|
+
video,
|
|
123
|
+
[
|
|
124
|
+
'channelThumbnailSupportedRenderers',
|
|
125
|
+
'channelThumbnailWithLinkRenderer',
|
|
126
|
+
'thumbnail',
|
|
127
|
+
'thumbnails',
|
|
128
|
+
],
|
|
129
|
+
),
|
|
130
|
+
},
|
|
131
|
+
'accessibility': {
|
|
132
|
+
'title': self._getValue(
|
|
133
|
+
video,
|
|
134
|
+
['title', 'accessibility', 'accessibilityData', 'label'],
|
|
135
|
+
),
|
|
136
|
+
'duration': self._getValue(
|
|
137
|
+
video,
|
|
138
|
+
['lengthText', 'accessibility', 'accessibilityData', 'label'],
|
|
139
|
+
),
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
component['link'] = _build_youtube_link(
|
|
143
|
+
'https://www.youtube.com/watch?v=',
|
|
144
|
+
video_id,
|
|
145
|
+
)
|
|
146
|
+
component['channel']['link'] = _build_youtube_link(
|
|
147
|
+
'https://www.youtube.com/channel/',
|
|
148
|
+
channel_id,
|
|
149
|
+
)
|
|
150
|
+
component['shelfTitle'] = shelfTitle
|
|
151
|
+
return component
|
|
152
|
+
|
|
153
|
+
# Apply patches
|
|
154
|
+
RequestCore.syncPostRequest = _patched_sync_post
|
|
155
|
+
RequestCore.syncGetRequest = _patched_sync_get
|
|
156
|
+
ComponentHandler._getVideoComponent = _patched_get_video_component
|
|
157
|
+
|
|
158
|
+
except ImportError:
|
|
159
|
+
pass # If import fails, continue without patching
|
|
160
|
+
|
|
161
|
+
from ellements.core.chunking import TextProcessor
|
|
162
|
+
from ellements.core.exceptions import LLMError
|
|
163
|
+
from youtubesearchpython import (
|
|
164
|
+
CustomSearch,
|
|
165
|
+
Video,
|
|
166
|
+
VideoDurationFilter,
|
|
167
|
+
VideoSortOrder,
|
|
168
|
+
VideosSearch,
|
|
169
|
+
VideoUploadDateFilter,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class VideoMetadata(BaseModel):
|
|
174
|
+
"""Metadata for a YouTube video."""
|
|
175
|
+
video_id: str = Field(description="YouTube video ID")
|
|
176
|
+
title: str = Field(description="Video title")
|
|
177
|
+
channel: str = Field(description="Channel name")
|
|
178
|
+
channel_id: str = Field(default="", description="Channel ID")
|
|
179
|
+
description: str = Field(default="", description="Video description")
|
|
180
|
+
duration: str = Field(default="", description="Video duration")
|
|
181
|
+
view_count: int = Field(default=0, description="Number of views")
|
|
182
|
+
publish_date: str = Field(default="", description="Publish date")
|
|
183
|
+
thumbnail_url: str = Field(default="", description="Thumbnail URL")
|
|
184
|
+
url: str = Field(description="Full YouTube URL")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class TranscriptSegment(BaseModel):
|
|
188
|
+
"""A single segment of a video transcript."""
|
|
189
|
+
text: str = Field(description="Text content of the segment")
|
|
190
|
+
start: float = Field(description="Start time in seconds")
|
|
191
|
+
duration: float = Field(description="Duration in seconds")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class VideoTranscript(BaseModel):
|
|
195
|
+
"""Full transcript for a YouTube video."""
|
|
196
|
+
video_id: str = Field(description="YouTube video ID")
|
|
197
|
+
language: str = Field(description="Language code of the transcript")
|
|
198
|
+
is_generated: bool = Field(default=False, description="Whether transcript is auto-generated")
|
|
199
|
+
segments: list[TranscriptSegment] = Field(description="List of transcript segments")
|
|
200
|
+
|
|
201
|
+
def get_full_text(self, separator: str = " ") -> str:
|
|
202
|
+
"""Get the full transcript as a single text string.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
separator: Separator between segments
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
Complete transcript text
|
|
209
|
+
"""
|
|
210
|
+
return separator.join(segment.text for segment in self.segments)
|
|
211
|
+
|
|
212
|
+
def get_text_at_time(self, timestamp: float, window: float = 5.0) -> str:
|
|
213
|
+
"""Get transcript text around a specific timestamp.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
timestamp: Time in seconds
|
|
217
|
+
window: Time window in seconds (before and after)
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
Text around the specified timestamp
|
|
221
|
+
"""
|
|
222
|
+
relevant_segments = [
|
|
223
|
+
seg for seg in self.segments
|
|
224
|
+
if seg.start <= timestamp <= seg.start + seg.duration + window
|
|
225
|
+
or timestamp - window <= seg.start <= timestamp + window
|
|
226
|
+
]
|
|
227
|
+
return " ".join(seg.text for seg in relevant_segments)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class SearchResult(BaseModel):
|
|
231
|
+
"""Represents a single YouTube search result."""
|
|
232
|
+
video_id: str = Field(description="YouTube video ID")
|
|
233
|
+
title: str = Field(description="Video title")
|
|
234
|
+
channel: str = Field(description="Channel name")
|
|
235
|
+
duration: str = Field(default="", description="Video duration")
|
|
236
|
+
view_count: str = Field(default="", description="View count as string")
|
|
237
|
+
publish_time: str = Field(default="", description="Time since published")
|
|
238
|
+
thumbnail_url: str = Field(default="", description="Thumbnail URL")
|
|
239
|
+
url: str = Field(description="Full YouTube URL")
|
|
240
|
+
description: str = Field(default="", description="Video description snippet")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class SearchResults(BaseModel):
|
|
244
|
+
"""Container for multiple YouTube search results."""
|
|
245
|
+
query: str = Field(description="The search query")
|
|
246
|
+
results: list[SearchResult] = Field(description="List of search results")
|
|
247
|
+
total_results: int = Field(description="Total number of results returned")
|
|
248
|
+
|
|
249
|
+
def __len__(self) -> int:
|
|
250
|
+
"""Return the number of results."""
|
|
251
|
+
return len(self.results)
|
|
252
|
+
|
|
253
|
+
def __iter__(self) -> Any:
|
|
254
|
+
"""Allow iteration over results."""
|
|
255
|
+
return iter(self.results)
|
|
256
|
+
|
|
257
|
+
def __getitem__(self, index: int) -> SearchResult:
|
|
258
|
+
"""Allow indexing of results."""
|
|
259
|
+
return self.results[index]
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _format_transcript_unavailable_message(
|
|
263
|
+
video_url_or_id: str,
|
|
264
|
+
error: Exception,
|
|
265
|
+
) -> str:
|
|
266
|
+
"""Build a model-friendly result for transcript fetch failures."""
|
|
267
|
+
reason = _extract_transcript_error_reason(error)
|
|
268
|
+
return (
|
|
269
|
+
"TRANSCRIPT_UNAVAILABLE\n"
|
|
270
|
+
f"Video: {video_url_or_id}\n"
|
|
271
|
+
f"Reason: {reason}\n"
|
|
272
|
+
"Do not stop the task. Continue with metadata and other search results, "
|
|
273
|
+
"and prefer videos whose transcripts you can verify. If this looks like "
|
|
274
|
+
"throttling, increase the delay with set_transcript_delay before retrying."
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _extract_transcript_error_reason(error: Exception) -> str:
|
|
279
|
+
"""Extract the most useful human-readable reason from a transcript error."""
|
|
280
|
+
candidates = [
|
|
281
|
+
getattr(error, "cause", None),
|
|
282
|
+
str(error),
|
|
283
|
+
]
|
|
284
|
+
for candidate in candidates:
|
|
285
|
+
text = str(candidate or "").strip()
|
|
286
|
+
if text:
|
|
287
|
+
return text
|
|
288
|
+
return error.__class__.__name__
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _is_transcript_rate_limited(error: Exception) -> bool:
|
|
292
|
+
"""Return whether a transcript error indicates YouTube-side throttling."""
|
|
293
|
+
haystack = _extract_transcript_error_reason(error).lower()
|
|
294
|
+
return "429" in haystack or "too many requests" in haystack
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _format_transcript_rate_limited_message(
|
|
298
|
+
video_url_or_id: str,
|
|
299
|
+
error: Exception,
|
|
300
|
+
) -> str:
|
|
301
|
+
"""Build a model-friendly result for active transcript rate limiting."""
|
|
302
|
+
reason = _extract_transcript_error_reason(error)
|
|
303
|
+
return (
|
|
304
|
+
"TRANSCRIPT_RATE_LIMITED\n"
|
|
305
|
+
f"Video: {video_url_or_id}\n"
|
|
306
|
+
f"Reason: {reason}\n"
|
|
307
|
+
"YouTube is currently blocking transcript requests from this environment. "
|
|
308
|
+
"Do not retry transcript fetches again in this run. Continue with metadata "
|
|
309
|
+
"and other search results instead. Increasing set_transcript_delay can help "
|
|
310
|
+
"avoid future throttling, but an active 429 block usually requires waiting "
|
|
311
|
+
"15-30 minutes or using proxy rotation before transcripts work again."
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _coerce_text_field(value: Any) -> str:
|
|
316
|
+
"""Normalize optional text fields returned by YouTube search libraries."""
|
|
317
|
+
if value is None:
|
|
318
|
+
return ""
|
|
319
|
+
if isinstance(value, str):
|
|
320
|
+
return value
|
|
321
|
+
if isinstance(value, (int, float)):
|
|
322
|
+
return str(value)
|
|
323
|
+
return ""
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class YouTubeSearcher:
|
|
327
|
+
"""YouTube search and transcript searcher."""
|
|
328
|
+
|
|
329
|
+
def __init__(
|
|
330
|
+
self,
|
|
331
|
+
language: str = "en",
|
|
332
|
+
region: str = "US",
|
|
333
|
+
**kwargs: Any
|
|
334
|
+
) -> None:
|
|
335
|
+
"""Initialize the YouTube searcher.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
language: Preferred language for searches and transcripts
|
|
339
|
+
region: Region for search results
|
|
340
|
+
**kwargs: Additional configuration
|
|
341
|
+
"""
|
|
342
|
+
self.language = language
|
|
343
|
+
self.region = region
|
|
344
|
+
self.config = kwargs
|
|
345
|
+
|
|
346
|
+
@staticmethod
|
|
347
|
+
def extract_video_id(url_or_id: str) -> str:
|
|
348
|
+
"""Extract video ID from a YouTube URL or return the ID if already extracted.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
url_or_id: YouTube URL or video ID
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
Video ID
|
|
355
|
+
|
|
356
|
+
Raises:
|
|
357
|
+
ValueError: If URL is invalid or ID cannot be extracted
|
|
358
|
+
"""
|
|
359
|
+
# If it's already a video ID (11 characters, alphanumeric with _ and -)
|
|
360
|
+
if re.match(r'^[a-zA-Z0-9_-]{11}$', url_or_id):
|
|
361
|
+
return url_or_id
|
|
362
|
+
|
|
363
|
+
# Parse various YouTube URL formats
|
|
364
|
+
parsed_url = urlparse(url_or_id)
|
|
365
|
+
|
|
366
|
+
# youtube.com/watch?v=VIDEO_ID
|
|
367
|
+
if parsed_url.hostname in ('www.youtube.com', 'youtube.com', 'm.youtube.com'):
|
|
368
|
+
if parsed_url.path == '/watch':
|
|
369
|
+
query_params = parse_qs(parsed_url.query)
|
|
370
|
+
if 'v' in query_params:
|
|
371
|
+
return query_params['v'][0]
|
|
372
|
+
# youtube.com/embed/VIDEO_ID
|
|
373
|
+
elif parsed_url.path.startswith('/embed/') or parsed_url.path.startswith('/v/'):
|
|
374
|
+
return parsed_url.path.split('/')[2]
|
|
375
|
+
|
|
376
|
+
# youtu.be/VIDEO_ID
|
|
377
|
+
if parsed_url.hostname in ('youtu.be', 'www.youtu.be'):
|
|
378
|
+
return parsed_url.path.lstrip('/')
|
|
379
|
+
|
|
380
|
+
raise ValueError(f"Could not extract video ID from: {url_or_id}")
|
|
381
|
+
|
|
382
|
+
@staticmethod
|
|
383
|
+
def _build_search_preferences(
|
|
384
|
+
duration: str | None = None,
|
|
385
|
+
upload_date: str | None = None,
|
|
386
|
+
sort_by: str | None = None,
|
|
387
|
+
) -> str:
|
|
388
|
+
"""Build a search preferences filter string for CustomSearch.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
duration: "short" (< 4 min) or "long" (> 20 min)
|
|
392
|
+
upload_date: "hour", "today", "week", "month", "year"
|
|
393
|
+
sort_by: "relevance", "date", "views", "rating"
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
Combined filter string, or empty string if no filters
|
|
397
|
+
"""
|
|
398
|
+
parts = []
|
|
399
|
+
|
|
400
|
+
if duration:
|
|
401
|
+
duration_map = {
|
|
402
|
+
"short": VideoDurationFilter.short,
|
|
403
|
+
"long": VideoDurationFilter.long,
|
|
404
|
+
}
|
|
405
|
+
if duration.lower() in duration_map:
|
|
406
|
+
parts.append(duration_map[duration.lower()])
|
|
407
|
+
|
|
408
|
+
if upload_date:
|
|
409
|
+
date_map = {
|
|
410
|
+
"hour": VideoUploadDateFilter.lastHour,
|
|
411
|
+
"today": VideoUploadDateFilter.today,
|
|
412
|
+
"week": VideoUploadDateFilter.thisWeek,
|
|
413
|
+
"month": VideoUploadDateFilter.thisMonth,
|
|
414
|
+
"year": VideoUploadDateFilter.thisYear,
|
|
415
|
+
}
|
|
416
|
+
if upload_date.lower() in date_map:
|
|
417
|
+
parts.append(date_map[upload_date.lower()])
|
|
418
|
+
|
|
419
|
+
if sort_by:
|
|
420
|
+
sort_map = {
|
|
421
|
+
"relevance": VideoSortOrder.relevance,
|
|
422
|
+
"date": VideoSortOrder.uploadDate,
|
|
423
|
+
"views": VideoSortOrder.viewCount,
|
|
424
|
+
"rating": VideoSortOrder.rating,
|
|
425
|
+
}
|
|
426
|
+
sort_key = sort_by.lower()
|
|
427
|
+
if sort_key == "relevance":
|
|
428
|
+
# Relevance is the default ordering for VideosSearch, so avoid
|
|
429
|
+
# sending it through CustomSearch unless other filters require it.
|
|
430
|
+
pass
|
|
431
|
+
elif sort_key in sort_map:
|
|
432
|
+
parts.append(sort_map[sort_key])
|
|
433
|
+
|
|
434
|
+
return "".join(parts)
|
|
435
|
+
|
|
436
|
+
def search(
|
|
437
|
+
self,
|
|
438
|
+
query: str,
|
|
439
|
+
max_results: int = 10,
|
|
440
|
+
duration: str | None = None,
|
|
441
|
+
upload_date: str | None = None,
|
|
442
|
+
sort_by: str | None = None,
|
|
443
|
+
**kwargs: Any,
|
|
444
|
+
) -> SearchResults:
|
|
445
|
+
"""Search for YouTube videos.
|
|
446
|
+
|
|
447
|
+
Args:
|
|
448
|
+
query: Search query string
|
|
449
|
+
max_results: Maximum number of results to return
|
|
450
|
+
duration: Filter by duration - "short" (< 4 min) or "long" (> 20 min)
|
|
451
|
+
upload_date: Filter by upload date - "hour", "today", "week", "month", "year"
|
|
452
|
+
sort_by: Sort order - "relevance", "date", "views", "rating"
|
|
453
|
+
**kwargs: Additional search parameters
|
|
454
|
+
|
|
455
|
+
Returns:
|
|
456
|
+
SearchResults object containing the results
|
|
457
|
+
"""
|
|
458
|
+
try:
|
|
459
|
+
# Build search preferences filter string if any filters specified
|
|
460
|
+
search_preferences = self._build_search_preferences(
|
|
461
|
+
duration=duration, upload_date=upload_date, sort_by=sort_by
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
if search_preferences:
|
|
465
|
+
# Use CustomSearch when filters are specified
|
|
466
|
+
videos_search = CustomSearch(
|
|
467
|
+
query, search_preferences, limit=max_results
|
|
468
|
+
)
|
|
469
|
+
else:
|
|
470
|
+
# Use simple VideosSearch when no filters
|
|
471
|
+
videos_search = VideosSearch(query, limit=max_results)
|
|
472
|
+
|
|
473
|
+
raw_results = videos_search.result()
|
|
474
|
+
|
|
475
|
+
# Convert to SearchResult objects
|
|
476
|
+
results = []
|
|
477
|
+
if 'result' in raw_results:
|
|
478
|
+
for video in raw_results['result']:
|
|
479
|
+
search_result = SearchResult(
|
|
480
|
+
video_id=_coerce_text_field(video.get('id', '')),
|
|
481
|
+
title=_coerce_text_field(video.get('title', '')),
|
|
482
|
+
channel=_coerce_text_field(
|
|
483
|
+
video.get('channel', {}).get('name', '')
|
|
484
|
+
),
|
|
485
|
+
duration=_coerce_text_field(video.get('duration', '')),
|
|
486
|
+
view_count=_coerce_text_field(
|
|
487
|
+
video.get('viewCount', {}).get('short', '')
|
|
488
|
+
),
|
|
489
|
+
publish_time=_coerce_text_field(
|
|
490
|
+
video.get('publishedTime', '')
|
|
491
|
+
),
|
|
492
|
+
thumbnail_url=(
|
|
493
|
+
_coerce_text_field(
|
|
494
|
+
video.get('thumbnails', [{}])[0].get('url', '')
|
|
495
|
+
)
|
|
496
|
+
if video.get('thumbnails')
|
|
497
|
+
else ''
|
|
498
|
+
),
|
|
499
|
+
url=_coerce_text_field(video.get('link', '')),
|
|
500
|
+
description=(
|
|
501
|
+
_coerce_text_field(
|
|
502
|
+
video.get('descriptionSnippet', [{}])[0].get(
|
|
503
|
+
'text', ''
|
|
504
|
+
)
|
|
505
|
+
)
|
|
506
|
+
if video.get('descriptionSnippet')
|
|
507
|
+
else ''
|
|
508
|
+
),
|
|
509
|
+
)
|
|
510
|
+
results.append(search_result)
|
|
511
|
+
|
|
512
|
+
return SearchResults(
|
|
513
|
+
query=query,
|
|
514
|
+
results=results,
|
|
515
|
+
total_results=len(results)
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
except Exception as e:
|
|
519
|
+
raise LLMError(f"YouTube search failed: {e}") from e
|
|
520
|
+
|
|
521
|
+
async def search_async(
|
|
522
|
+
self,
|
|
523
|
+
query: str,
|
|
524
|
+
max_results: int = 10,
|
|
525
|
+
**kwargs: Any,
|
|
526
|
+
) -> SearchResults:
|
|
527
|
+
"""Async version of search.
|
|
528
|
+
|
|
529
|
+
Args:
|
|
530
|
+
query: Search query string
|
|
531
|
+
max_results: Maximum number of results
|
|
532
|
+
**kwargs: Additional parameters
|
|
533
|
+
|
|
534
|
+
Returns:
|
|
535
|
+
SearchResults object
|
|
536
|
+
"""
|
|
537
|
+
# Run synchronous search in thread pool
|
|
538
|
+
loop = asyncio.get_event_loop()
|
|
539
|
+
return await loop.run_in_executor(
|
|
540
|
+
None,
|
|
541
|
+
lambda: self.search(query=query, max_results=max_results, **kwargs)
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
def get_video_metadata(
|
|
545
|
+
self,
|
|
546
|
+
video_url_or_id: str,
|
|
547
|
+
**kwargs: Any,
|
|
548
|
+
) -> VideoMetadata:
|
|
549
|
+
"""Get detailed metadata for a YouTube video.
|
|
550
|
+
|
|
551
|
+
Args:
|
|
552
|
+
video_url_or_id: YouTube URL or video ID
|
|
553
|
+
**kwargs: Additional parameters
|
|
554
|
+
|
|
555
|
+
Returns:
|
|
556
|
+
VideoMetadata object
|
|
557
|
+
"""
|
|
558
|
+
try:
|
|
559
|
+
video_id = self.extract_video_id(video_url_or_id)
|
|
560
|
+
|
|
561
|
+
# Filter kwargs to only include supported parameters for Video.getInfo
|
|
562
|
+
# Video.getInfo accepts: mode, timeout
|
|
563
|
+
video_kwargs = {}
|
|
564
|
+
supported_params = {'mode', 'timeout'}
|
|
565
|
+
for key, value in kwargs.items():
|
|
566
|
+
if key in supported_params:
|
|
567
|
+
video_kwargs[key] = value
|
|
568
|
+
|
|
569
|
+
# Use Video class to get detailed info
|
|
570
|
+
video_info = Video.getInfo(f"https://www.youtube.com/watch?v={video_id}", **video_kwargs)
|
|
571
|
+
|
|
572
|
+
return VideoMetadata(
|
|
573
|
+
video_id=video_id,
|
|
574
|
+
title=video_info.get('title', ''),
|
|
575
|
+
channel=video_info.get('channel', {}).get('name', ''),
|
|
576
|
+
channel_id=video_info.get('channel', {}).get('id', ''),
|
|
577
|
+
description=video_info.get('description', ''),
|
|
578
|
+
duration=video_info.get('duration', {}).get('secondsText', ''),
|
|
579
|
+
view_count=int(video_info.get('viewCount', {}).get('text', '0').replace(',', '').replace(' views', '').split()[0]) if video_info.get('viewCount') else 0,
|
|
580
|
+
publish_date=video_info.get('publishDate', ''),
|
|
581
|
+
thumbnail_url=video_info.get('thumbnails', [{}])[-1].get('url', '') if video_info.get('thumbnails') else '',
|
|
582
|
+
url=f"https://www.youtube.com/watch?v={video_id}"
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
except Exception as e:
|
|
586
|
+
raise LLMError(f"Failed to get video metadata: {e}") from e
|
|
587
|
+
|
|
588
|
+
async def get_video_metadata_async(
|
|
589
|
+
self,
|
|
590
|
+
video_url_or_id: str,
|
|
591
|
+
**kwargs: Any,
|
|
592
|
+
) -> VideoMetadata:
|
|
593
|
+
"""Async version of get_video_metadata.
|
|
594
|
+
|
|
595
|
+
Args:
|
|
596
|
+
video_url_or_id: YouTube URL or video ID
|
|
597
|
+
**kwargs: Additional parameters
|
|
598
|
+
|
|
599
|
+
Returns:
|
|
600
|
+
VideoMetadata object
|
|
601
|
+
"""
|
|
602
|
+
loop = asyncio.get_event_loop()
|
|
603
|
+
return await loop.run_in_executor(
|
|
604
|
+
None,
|
|
605
|
+
lambda: self.get_video_metadata(video_url_or_id, **kwargs)
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
def get_transcript(
|
|
609
|
+
self,
|
|
610
|
+
video_url_or_id: str,
|
|
611
|
+
languages: list[str] | None = None,
|
|
612
|
+
preserve_formatting: bool = False,
|
|
613
|
+
**kwargs: Any,
|
|
614
|
+
) -> VideoTranscript:
|
|
615
|
+
"""Get transcript for a YouTube video.
|
|
616
|
+
|
|
617
|
+
Args:
|
|
618
|
+
video_url_or_id: YouTube URL or video ID
|
|
619
|
+
languages: Preferred languages (e.g., ['en', 'es'])
|
|
620
|
+
preserve_formatting: Preserve line breaks and formatting
|
|
621
|
+
**kwargs: Additional parameters
|
|
622
|
+
|
|
623
|
+
Returns:
|
|
624
|
+
VideoTranscript object
|
|
625
|
+
|
|
626
|
+
Raises:
|
|
627
|
+
LLMError: If transcript cannot be fetched
|
|
628
|
+
"""
|
|
629
|
+
try:
|
|
630
|
+
video_id = self.extract_video_id(video_url_or_id)
|
|
631
|
+
|
|
632
|
+
# Default to instance language if not specified
|
|
633
|
+
if languages is None:
|
|
634
|
+
languages = [self.language]
|
|
635
|
+
|
|
636
|
+
# Create API instance and fetch transcript
|
|
637
|
+
api = YouTubeTranscriptApi()
|
|
638
|
+
|
|
639
|
+
# Try to fetch transcript (tries all available transcripts)
|
|
640
|
+
fetched_transcript = api.fetch(video_id, **kwargs)
|
|
641
|
+
|
|
642
|
+
# Convert snippets to TranscriptSegment objects
|
|
643
|
+
segments = []
|
|
644
|
+
for snippet in fetched_transcript.snippets:
|
|
645
|
+
segments.append(TranscriptSegment(
|
|
646
|
+
text=snippet.text,
|
|
647
|
+
start=snippet.start,
|
|
648
|
+
duration=snippet.duration
|
|
649
|
+
))
|
|
650
|
+
|
|
651
|
+
return VideoTranscript(
|
|
652
|
+
video_id=video_id,
|
|
653
|
+
language=fetched_transcript.language_code,
|
|
654
|
+
is_generated=fetched_transcript.is_generated,
|
|
655
|
+
segments=segments
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
except Exception as e:
|
|
659
|
+
raise LLMError(f"Failed to fetch transcript: {e}") from e
|
|
660
|
+
|
|
661
|
+
async def get_transcript_async(
|
|
662
|
+
self,
|
|
663
|
+
video_url_or_id: str,
|
|
664
|
+
languages: list[str] | None = None,
|
|
665
|
+
preserve_formatting: bool = False,
|
|
666
|
+
**kwargs: Any,
|
|
667
|
+
) -> VideoTranscript:
|
|
668
|
+
"""Async version of get_transcript.
|
|
669
|
+
|
|
670
|
+
Args:
|
|
671
|
+
video_url_or_id: YouTube URL or video ID
|
|
672
|
+
languages: Preferred languages
|
|
673
|
+
preserve_formatting: Preserve formatting
|
|
674
|
+
**kwargs: Additional parameters
|
|
675
|
+
|
|
676
|
+
Returns:
|
|
677
|
+
VideoTranscript object
|
|
678
|
+
"""
|
|
679
|
+
loop = asyncio.get_event_loop()
|
|
680
|
+
return await loop.run_in_executor(
|
|
681
|
+
None,
|
|
682
|
+
lambda: self.get_transcript(
|
|
683
|
+
video_url_or_id,
|
|
684
|
+
languages=languages,
|
|
685
|
+
preserve_formatting=preserve_formatting,
|
|
686
|
+
**kwargs
|
|
687
|
+
)
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
def get_video_with_transcript(
|
|
691
|
+
self,
|
|
692
|
+
video_url_or_id: str,
|
|
693
|
+
languages: list[str] | None = None,
|
|
694
|
+
**kwargs: Any,
|
|
695
|
+
) -> tuple[VideoMetadata, VideoTranscript]:
|
|
696
|
+
"""Get both metadata and transcript for a video.
|
|
697
|
+
|
|
698
|
+
Args:
|
|
699
|
+
video_url_or_id: YouTube URL or video ID
|
|
700
|
+
languages: Preferred languages for transcript
|
|
701
|
+
**kwargs: Additional parameters
|
|
702
|
+
|
|
703
|
+
Returns:
|
|
704
|
+
Tuple of (VideoMetadata, VideoTranscript)
|
|
705
|
+
"""
|
|
706
|
+
metadata = self.get_video_metadata(video_url_or_id, **kwargs)
|
|
707
|
+
transcript = self.get_transcript(video_url_or_id, languages=languages, **kwargs)
|
|
708
|
+
return metadata, transcript
|
|
709
|
+
|
|
710
|
+
async def get_video_with_transcript_async(
|
|
711
|
+
self,
|
|
712
|
+
video_url_or_id: str,
|
|
713
|
+
languages: list[str] | None = None,
|
|
714
|
+
**kwargs: Any,
|
|
715
|
+
) -> tuple[VideoMetadata, VideoTranscript]:
|
|
716
|
+
"""Async version of get_video_with_transcript.
|
|
717
|
+
|
|
718
|
+
Args:
|
|
719
|
+
video_url_or_id: YouTube URL or video ID
|
|
720
|
+
languages: Preferred languages
|
|
721
|
+
**kwargs: Additional parameters
|
|
722
|
+
|
|
723
|
+
Returns:
|
|
724
|
+
Tuple of (VideoMetadata, VideoTranscript)
|
|
725
|
+
"""
|
|
726
|
+
# Run both operations concurrently
|
|
727
|
+
metadata_task = self.get_video_metadata_async(video_url_or_id, **kwargs)
|
|
728
|
+
transcript_task = self.get_transcript_async(video_url_or_id, languages=languages, **kwargs)
|
|
729
|
+
|
|
730
|
+
metadata, transcript = await asyncio.gather(metadata_task, transcript_task)
|
|
731
|
+
return metadata, transcript
|
|
732
|
+
|
|
733
|
+
def search_and_get_transcripts(
|
|
734
|
+
self,
|
|
735
|
+
query: str,
|
|
736
|
+
max_results: int = 5,
|
|
737
|
+
languages: list[str] | None = None,
|
|
738
|
+
**kwargs: Any,
|
|
739
|
+
) -> list[tuple[SearchResult, VideoTranscript]]:
|
|
740
|
+
"""Search for videos and get their transcripts.
|
|
741
|
+
|
|
742
|
+
Args:
|
|
743
|
+
query: Search query
|
|
744
|
+
max_results: Maximum number of results
|
|
745
|
+
languages: Preferred languages for transcripts
|
|
746
|
+
**kwargs: Additional parameters
|
|
747
|
+
|
|
748
|
+
Returns:
|
|
749
|
+
List of tuples (SearchResult, VideoTranscript)
|
|
750
|
+
"""
|
|
751
|
+
search_results = self.search(query, max_results=max_results, **kwargs)
|
|
752
|
+
|
|
753
|
+
results_with_transcripts = []
|
|
754
|
+
for result in search_results.results:
|
|
755
|
+
try:
|
|
756
|
+
transcript = self.get_transcript(result.video_id, languages=languages)
|
|
757
|
+
results_with_transcripts.append((result, transcript))
|
|
758
|
+
except Exception:
|
|
759
|
+
# Skip videos without transcripts
|
|
760
|
+
continue
|
|
761
|
+
|
|
762
|
+
return results_with_transcripts
|
|
763
|
+
|
|
764
|
+
async def search_and_get_transcripts_async(
|
|
765
|
+
self,
|
|
766
|
+
query: str,
|
|
767
|
+
max_results: int = 5,
|
|
768
|
+
languages: list[str] | None = None,
|
|
769
|
+
max_concurrent: int = 3,
|
|
770
|
+
**kwargs: Any,
|
|
771
|
+
) -> list[tuple[SearchResult, VideoTranscript]]:
|
|
772
|
+
"""Async version of search_and_get_transcripts.
|
|
773
|
+
|
|
774
|
+
Args:
|
|
775
|
+
query: Search query
|
|
776
|
+
max_results: Maximum number of results
|
|
777
|
+
languages: Preferred languages
|
|
778
|
+
max_concurrent: Maximum concurrent transcript fetches
|
|
779
|
+
**kwargs: Additional parameters
|
|
780
|
+
|
|
781
|
+
Returns:
|
|
782
|
+
List of tuples (SearchResult, VideoTranscript)
|
|
783
|
+
"""
|
|
784
|
+
search_results = await self.search_async(query, max_results=max_results, **kwargs)
|
|
785
|
+
|
|
786
|
+
semaphore = asyncio.Semaphore(max_concurrent)
|
|
787
|
+
|
|
788
|
+
async def fetch_transcript_safe(result: SearchResult) -> tuple[SearchResult, VideoTranscript] | None:
|
|
789
|
+
async with semaphore:
|
|
790
|
+
try:
|
|
791
|
+
transcript = await self.get_transcript_async(result.video_id, languages=languages)
|
|
792
|
+
return (result, transcript)
|
|
793
|
+
except Exception:
|
|
794
|
+
return None
|
|
795
|
+
|
|
796
|
+
tasks = [fetch_transcript_safe(result) for result in search_results.results]
|
|
797
|
+
results = await asyncio.gather(*tasks)
|
|
798
|
+
|
|
799
|
+
# Filter out None results (videos without transcripts)
|
|
800
|
+
return [r for r in results if r is not None]
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
class RateLimitedYouTubeSearcher(YouTubeSearcher):
|
|
804
|
+
"""YouTube searcher with rate limiting, retry logic, and proxy support.
|
|
805
|
+
|
|
806
|
+
This class extends YouTubeSearcher to prevent IP blocking by:
|
|
807
|
+
1. Adding configurable delays between transcript fetches
|
|
808
|
+
2. Implementing retry logic with exponential backoff
|
|
809
|
+
3. Supporting proxy configuration
|
|
810
|
+
4. Optional transcript caching
|
|
811
|
+
|
|
812
|
+
Example:
|
|
813
|
+
>>> # Basic rate limiting (2 second delay between requests)
|
|
814
|
+
>>> searcher = RateLimitedYouTubeSearcher(
|
|
815
|
+
... rate_limit_delay=2.0,
|
|
816
|
+
... max_retries=3
|
|
817
|
+
... )
|
|
818
|
+
>>>
|
|
819
|
+
>>> # With proxy support (requires youtube-transcript-api with proxy support)
|
|
820
|
+
>>> from youtube_transcript_api.proxies import WebshareProxyConfig
|
|
821
|
+
>>> proxy_config = WebshareProxyConfig(
|
|
822
|
+
... proxy_username="user",
|
|
823
|
+
... proxy_password="pass"
|
|
824
|
+
... )
|
|
825
|
+
>>> searcher = RateLimitedYouTubeSearcher(
|
|
826
|
+
... rate_limit_delay=2.0,
|
|
827
|
+
... proxy_config=proxy_config
|
|
828
|
+
... )
|
|
829
|
+
"""
|
|
830
|
+
|
|
831
|
+
def __init__(
|
|
832
|
+
self,
|
|
833
|
+
language: str = "en",
|
|
834
|
+
region: str = "US",
|
|
835
|
+
rate_limit_delay: float = 1.5,
|
|
836
|
+
max_retries: int = 3,
|
|
837
|
+
retry_backoff_factor: float = 2.0,
|
|
838
|
+
rate_limit_cooldown_seconds: float = 900.0,
|
|
839
|
+
enable_cache: bool = True,
|
|
840
|
+
cache_size: int = 128,
|
|
841
|
+
proxy_config: Any | None = None,
|
|
842
|
+
progress_callback: Callable[[str], None] | None = None,
|
|
843
|
+
**kwargs: Any
|
|
844
|
+
) -> None:
|
|
845
|
+
"""Initialize the rate-limited YouTube searcher.
|
|
846
|
+
|
|
847
|
+
Args:
|
|
848
|
+
language: Preferred language for searches and transcripts
|
|
849
|
+
region: Region for search results
|
|
850
|
+
rate_limit_delay: Delay in seconds between transcript requests (default: 1.5s)
|
|
851
|
+
max_retries: Maximum number of retry attempts (default: 3)
|
|
852
|
+
retry_backoff_factor: Exponential backoff multiplier (default: 2.0)
|
|
853
|
+
rate_limit_cooldown_seconds: How long to stop retrying after YouTube returns
|
|
854
|
+
a transcript 429 block (default: 900s / 15 minutes)
|
|
855
|
+
enable_cache: Enable transcript caching to avoid redundant requests
|
|
856
|
+
cache_size: Maximum number of cached transcripts (default: 128)
|
|
857
|
+
proxy_config: Optional proxy configuration (WebshareProxyConfig or GenericProxyConfig)
|
|
858
|
+
progress_callback: Optional callback for progress updates (e.g., rate limit waits)
|
|
859
|
+
**kwargs: Additional configuration
|
|
860
|
+
"""
|
|
861
|
+
super().__init__(language=language, region=region, **kwargs)
|
|
862
|
+
|
|
863
|
+
self.rate_limit_delay = rate_limit_delay
|
|
864
|
+
self.max_retries = max_retries
|
|
865
|
+
self.retry_backoff_factor = retry_backoff_factor
|
|
866
|
+
self.rate_limit_cooldown_seconds = max(0.0, rate_limit_cooldown_seconds)
|
|
867
|
+
self.enable_cache = enable_cache
|
|
868
|
+
self.proxy_config = proxy_config
|
|
869
|
+
self.progress_callback = progress_callback
|
|
870
|
+
|
|
871
|
+
# Track last request time for rate limiting
|
|
872
|
+
self._last_transcript_request = 0.0
|
|
873
|
+
self._transcript_rate_limited_until = 0.0
|
|
874
|
+
self._transcript_rate_limit_reason: str | None = None
|
|
875
|
+
|
|
876
|
+
# Create cached version of get_transcript if caching enabled
|
|
877
|
+
if enable_cache:
|
|
878
|
+
self._cached_get_transcript = lru_cache(maxsize=cache_size)(
|
|
879
|
+
self._get_transcript_impl
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
def _get_active_transcript_rate_limit_error(self) -> LLMError | None:
|
|
883
|
+
"""Return the active transcript rate-limit error, if still in cooldown."""
|
|
884
|
+
if self._transcript_rate_limited_until <= 0:
|
|
885
|
+
return None
|
|
886
|
+
|
|
887
|
+
now = time.time()
|
|
888
|
+
if now >= self._transcript_rate_limited_until:
|
|
889
|
+
self._transcript_rate_limited_until = 0.0
|
|
890
|
+
self._transcript_rate_limit_reason = None
|
|
891
|
+
return None
|
|
892
|
+
|
|
893
|
+
remaining_seconds = max(
|
|
894
|
+
1,
|
|
895
|
+
int(round(self._transcript_rate_limited_until - now)),
|
|
896
|
+
)
|
|
897
|
+
reason = self._transcript_rate_limit_reason or "HTTP 429 Too Many Requests"
|
|
898
|
+
return LLMError(
|
|
899
|
+
"Transcript requests are temporarily blocked by YouTube for this "
|
|
900
|
+
f"environment. Wait about {remaining_seconds}s before retrying, or "
|
|
901
|
+
f"use proxy rotation. Last error: {reason}"
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
def _mark_transcript_rate_limited(self, error: Exception) -> None:
|
|
905
|
+
"""Record an active transcript 429 block for the current environment."""
|
|
906
|
+
if self.rate_limit_cooldown_seconds <= 0:
|
|
907
|
+
return
|
|
908
|
+
self._transcript_rate_limited_until = (
|
|
909
|
+
time.time() + self.rate_limit_cooldown_seconds
|
|
910
|
+
)
|
|
911
|
+
self._transcript_rate_limit_reason = _extract_transcript_error_reason(error)
|
|
912
|
+
|
|
913
|
+
def _wait_for_rate_limit(self) -> None:
|
|
914
|
+
"""Wait if necessary to respect rate limiting."""
|
|
915
|
+
if self.rate_limit_delay <= 0:
|
|
916
|
+
return
|
|
917
|
+
|
|
918
|
+
time_since_last = time.time() - self._last_transcript_request
|
|
919
|
+
if time_since_last < self.rate_limit_delay:
|
|
920
|
+
wait_time = self.rate_limit_delay - time_since_last
|
|
921
|
+
|
|
922
|
+
# Notify user of rate limit wait
|
|
923
|
+
if self.progress_callback:
|
|
924
|
+
self.progress_callback(f"⏳ Rate limit: waiting {wait_time:.1f}s before next request...")
|
|
925
|
+
|
|
926
|
+
time.sleep(wait_time)
|
|
927
|
+
|
|
928
|
+
self._last_transcript_request = time.time()
|
|
929
|
+
|
|
930
|
+
def _get_transcript_impl(
|
|
931
|
+
self,
|
|
932
|
+
video_id: str,
|
|
933
|
+
languages: tuple[str, ...] | None = None,
|
|
934
|
+
preserve_formatting: bool = False,
|
|
935
|
+
) -> VideoTranscript:
|
|
936
|
+
"""Internal implementation for getting transcripts.
|
|
937
|
+
|
|
938
|
+
This method is used for caching. Note: languages must be tuple for hashability.
|
|
939
|
+
|
|
940
|
+
Args:
|
|
941
|
+
video_id: YouTube video ID
|
|
942
|
+
languages: Preferred languages as tuple
|
|
943
|
+
preserve_formatting: Preserve line breaks
|
|
944
|
+
|
|
945
|
+
Returns:
|
|
946
|
+
VideoTranscript object
|
|
947
|
+
"""
|
|
948
|
+
active_rate_limit_error = self._get_active_transcript_rate_limit_error()
|
|
949
|
+
if active_rate_limit_error is not None:
|
|
950
|
+
raise active_rate_limit_error
|
|
951
|
+
|
|
952
|
+
# Wait for rate limit before making request
|
|
953
|
+
self._wait_for_rate_limit()
|
|
954
|
+
|
|
955
|
+
# Retry logic with exponential backoff
|
|
956
|
+
last_exception = None
|
|
957
|
+
for attempt in range(self.max_retries):
|
|
958
|
+
try:
|
|
959
|
+
# Create API instance with optional proxy
|
|
960
|
+
if self.proxy_config:
|
|
961
|
+
api = YouTubeTranscriptApi(proxy_config=self.proxy_config)
|
|
962
|
+
else:
|
|
963
|
+
api = YouTubeTranscriptApi()
|
|
964
|
+
|
|
965
|
+
# Try to fetch transcript
|
|
966
|
+
fetched_transcript = api.fetch(video_id)
|
|
967
|
+
|
|
968
|
+
# Convert snippets to TranscriptSegment objects
|
|
969
|
+
segments = []
|
|
970
|
+
for snippet in fetched_transcript.snippets:
|
|
971
|
+
segments.append(TranscriptSegment(
|
|
972
|
+
text=snippet.text,
|
|
973
|
+
start=snippet.start,
|
|
974
|
+
duration=snippet.duration
|
|
975
|
+
))
|
|
976
|
+
|
|
977
|
+
self._transcript_rate_limited_until = 0.0
|
|
978
|
+
self._transcript_rate_limit_reason = None
|
|
979
|
+
return VideoTranscript(
|
|
980
|
+
video_id=video_id,
|
|
981
|
+
language=fetched_transcript.language_code,
|
|
982
|
+
is_generated=fetched_transcript.is_generated,
|
|
983
|
+
segments=segments
|
|
984
|
+
)
|
|
985
|
+
|
|
986
|
+
except Exception as e:
|
|
987
|
+
last_exception = e
|
|
988
|
+
|
|
989
|
+
if _is_transcript_rate_limited(e):
|
|
990
|
+
self._mark_transcript_rate_limited(e)
|
|
991
|
+
|
|
992
|
+
if self.progress_callback:
|
|
993
|
+
cooldown_seconds = max(
|
|
994
|
+
1,
|
|
995
|
+
int(round(self.rate_limit_cooldown_seconds)),
|
|
996
|
+
)
|
|
997
|
+
self.progress_callback(
|
|
998
|
+
"⚠️ YouTube is rate limiting transcript requests "
|
|
999
|
+
f"(HTTP 429). Skipping further transcript retries for "
|
|
1000
|
+
f"about {cooldown_seconds}s."
|
|
1001
|
+
)
|
|
1002
|
+
break
|
|
1003
|
+
|
|
1004
|
+
# If this is not the last attempt, wait before retrying
|
|
1005
|
+
if attempt < self.max_retries - 1:
|
|
1006
|
+
# Exponential backoff: wait longer after each failure
|
|
1007
|
+
backoff_time = self.rate_limit_delay * (self.retry_backoff_factor ** attempt)
|
|
1008
|
+
|
|
1009
|
+
# Notify user of retry
|
|
1010
|
+
if self.progress_callback:
|
|
1011
|
+
self.progress_callback(
|
|
1012
|
+
f"⚠️ Retry attempt {attempt + 1}/{self.max_retries} failed. "
|
|
1013
|
+
f"Waiting {backoff_time:.1f}s before retry {attempt + 2}..."
|
|
1014
|
+
)
|
|
1015
|
+
|
|
1016
|
+
time.sleep(backoff_time)
|
|
1017
|
+
|
|
1018
|
+
active_rate_limit_error = self._get_active_transcript_rate_limit_error()
|
|
1019
|
+
if active_rate_limit_error is not None:
|
|
1020
|
+
raise active_rate_limit_error from last_exception
|
|
1021
|
+
|
|
1022
|
+
# All retries exhausted
|
|
1023
|
+
raise LLMError(
|
|
1024
|
+
f"Failed to fetch transcript after {self.max_retries} attempts: {last_exception}"
|
|
1025
|
+
) from last_exception
|
|
1026
|
+
|
|
1027
|
+
def get_transcript(
|
|
1028
|
+
self,
|
|
1029
|
+
video_url_or_id: str,
|
|
1030
|
+
languages: list[str] | None = None,
|
|
1031
|
+
preserve_formatting: bool = False,
|
|
1032
|
+
**kwargs: Any,
|
|
1033
|
+
) -> VideoTranscript:
|
|
1034
|
+
"""Get transcript for a YouTube video with rate limiting and retry logic.
|
|
1035
|
+
|
|
1036
|
+
This method includes:
|
|
1037
|
+
- Rate limiting to prevent IP blocking
|
|
1038
|
+
- Exponential backoff retry on failures
|
|
1039
|
+
- Optional caching to avoid redundant requests
|
|
1040
|
+
- Optional proxy support
|
|
1041
|
+
|
|
1042
|
+
Args:
|
|
1043
|
+
video_url_or_id: YouTube URL or video ID
|
|
1044
|
+
languages: Preferred languages (e.g., ['en', 'es'])
|
|
1045
|
+
preserve_formatting: Preserve line breaks and formatting
|
|
1046
|
+
**kwargs: Additional parameters
|
|
1047
|
+
|
|
1048
|
+
Returns:
|
|
1049
|
+
VideoTranscript object
|
|
1050
|
+
|
|
1051
|
+
Raises:
|
|
1052
|
+
LLMError: If transcript cannot be fetched after all retries
|
|
1053
|
+
"""
|
|
1054
|
+
try:
|
|
1055
|
+
video_id = self.extract_video_id(video_url_or_id)
|
|
1056
|
+
|
|
1057
|
+
# Default to instance language if not specified
|
|
1058
|
+
if languages is None:
|
|
1059
|
+
languages_tuple: tuple[str, ...] = (self.language,)
|
|
1060
|
+
else:
|
|
1061
|
+
# Convert to tuple for hashability (needed for caching)
|
|
1062
|
+
languages_tuple = tuple(languages)
|
|
1063
|
+
|
|
1064
|
+
# Use cached version if caching enabled
|
|
1065
|
+
if self.enable_cache:
|
|
1066
|
+
return self._cached_get_transcript(
|
|
1067
|
+
video_id=video_id,
|
|
1068
|
+
languages=languages_tuple,
|
|
1069
|
+
preserve_formatting=preserve_formatting
|
|
1070
|
+
)
|
|
1071
|
+
else:
|
|
1072
|
+
return self._get_transcript_impl(
|
|
1073
|
+
video_id=video_id,
|
|
1074
|
+
languages=languages_tuple,
|
|
1075
|
+
preserve_formatting=preserve_formatting
|
|
1076
|
+
)
|
|
1077
|
+
|
|
1078
|
+
except Exception as e:
|
|
1079
|
+
# Re-raise LLMError as-is, wrap others
|
|
1080
|
+
if isinstance(e, LLMError):
|
|
1081
|
+
raise
|
|
1082
|
+
raise LLMError(f"Failed to fetch transcript: {e}") from e
|
|
1083
|
+
|
|
1084
|
+
async def get_transcript_async(
|
|
1085
|
+
self,
|
|
1086
|
+
video_url_or_id: str,
|
|
1087
|
+
languages: list[str] | None = None,
|
|
1088
|
+
preserve_formatting: bool = False,
|
|
1089
|
+
**kwargs: Any,
|
|
1090
|
+
) -> VideoTranscript:
|
|
1091
|
+
"""Async version of get_transcript with rate limiting.
|
|
1092
|
+
|
|
1093
|
+
Args:
|
|
1094
|
+
video_url_or_id: YouTube URL or video ID
|
|
1095
|
+
languages: Preferred languages
|
|
1096
|
+
preserve_formatting: Preserve formatting
|
|
1097
|
+
**kwargs: Additional parameters
|
|
1098
|
+
|
|
1099
|
+
Returns:
|
|
1100
|
+
VideoTranscript object
|
|
1101
|
+
"""
|
|
1102
|
+
loop = asyncio.get_event_loop()
|
|
1103
|
+
return await loop.run_in_executor(
|
|
1104
|
+
None,
|
|
1105
|
+
lambda: self.get_transcript(
|
|
1106
|
+
video_url_or_id,
|
|
1107
|
+
languages=languages,
|
|
1108
|
+
preserve_formatting=preserve_formatting,
|
|
1109
|
+
**kwargs
|
|
1110
|
+
)
|
|
1111
|
+
)
|
|
1112
|
+
|
|
1113
|
+
def clear_cache(self) -> None:
|
|
1114
|
+
"""Clear the transcript cache if caching is enabled."""
|
|
1115
|
+
if self.enable_cache and hasattr(self, '_cached_get_transcript'):
|
|
1116
|
+
self._cached_get_transcript.cache_clear()
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
# Agent Tools
|
|
1120
|
+
# ---------------------------------------------------------------------------
|
|
1121
|
+
|
|
1122
|
+
def youtube_search_tools(
|
|
1123
|
+
text_processor: TextProcessor | None = None,
|
|
1124
|
+
max_transcript_tokens: int | None = None,
|
|
1125
|
+
truncation_strategy: str = "simple",
|
|
1126
|
+
rate_limit_delay: float = 1.5,
|
|
1127
|
+
max_retries: int = 3,
|
|
1128
|
+
enable_cache: bool = True,
|
|
1129
|
+
proxy_config: Any | None = None,
|
|
1130
|
+
progress_callback: Callable[[str], None] | None = None,
|
|
1131
|
+
) -> ToolRegistry:
|
|
1132
|
+
"""Create YouTube search and transcript tools for agents.
|
|
1133
|
+
|
|
1134
|
+
These tools are returned as plain callables. Framework-specific backends
|
|
1135
|
+
adapt them when needed.
|
|
1136
|
+
|
|
1137
|
+
By default, uses RateLimitedYouTubeSearcher to prevent IP blocking with:
|
|
1138
|
+
- 1.5 second delay between transcript requests
|
|
1139
|
+
- 3 retry attempts with exponential backoff
|
|
1140
|
+
- Transcript caching to avoid redundant requests
|
|
1141
|
+
|
|
1142
|
+
Args:
|
|
1143
|
+
text_processor: Optional TextProcessor for handling long transcripts.
|
|
1144
|
+
If not provided but max_transcript_tokens is set,
|
|
1145
|
+
a default processor will be created.
|
|
1146
|
+
max_transcript_tokens: Maximum tokens for transcripts. If None, no truncation.
|
|
1147
|
+
truncation_strategy: Strategy to use ("simple", "map_reduce", "sequential")
|
|
1148
|
+
rate_limit_delay: Delay in seconds between transcript requests (default: 1.5).
|
|
1149
|
+
Set to 0 to disable rate limiting.
|
|
1150
|
+
max_retries: Maximum retry attempts on failures (default: 3)
|
|
1151
|
+
enable_cache: Enable transcript caching (default: True)
|
|
1152
|
+
proxy_config: Optional proxy configuration (WebshareProxyConfig or GenericProxyConfig)
|
|
1153
|
+
|
|
1154
|
+
Returns:
|
|
1155
|
+
Dict of plain tool callables ready for adaptation by agent frameworks
|
|
1156
|
+
|
|
1157
|
+
Example:
|
|
1158
|
+
>>> # Default with rate limiting (recommended)
|
|
1159
|
+
>>> tools = youtube_search_tools(max_transcript_tokens=4000)
|
|
1160
|
+
>>>
|
|
1161
|
+
>>> # With custom rate limiting
|
|
1162
|
+
>>> tools = youtube_search_tools(
|
|
1163
|
+
... max_transcript_tokens=4000,
|
|
1164
|
+
... rate_limit_delay=2.0, # 2 seconds between requests
|
|
1165
|
+
... max_retries=5
|
|
1166
|
+
... )
|
|
1167
|
+
>>>
|
|
1168
|
+
>>> # With proxy support
|
|
1169
|
+
>>> from youtube_transcript_api.proxies import WebshareProxyConfig
|
|
1170
|
+
>>> proxy_config = WebshareProxyConfig(
|
|
1171
|
+
... proxy_username="user",
|
|
1172
|
+
... proxy_password="pass"
|
|
1173
|
+
... )
|
|
1174
|
+
>>> tools = youtube_search_tools(
|
|
1175
|
+
... proxy_config=proxy_config,
|
|
1176
|
+
... rate_limit_delay=1.0
|
|
1177
|
+
... )
|
|
1178
|
+
>>>
|
|
1179
|
+
>>> # Disable rate limiting (not recommended, may cause IP blocking)
|
|
1180
|
+
>>> tools = youtube_search_tools(rate_limit_delay=0)
|
|
1181
|
+
"""
|
|
1182
|
+
|
|
1183
|
+
# Use rate-limited searcher by default to prevent IP blocking
|
|
1184
|
+
searcher = RateLimitedYouTubeSearcher(
|
|
1185
|
+
rate_limit_delay=rate_limit_delay,
|
|
1186
|
+
max_retries=max_retries,
|
|
1187
|
+
enable_cache=enable_cache,
|
|
1188
|
+
proxy_config=proxy_config,
|
|
1189
|
+
progress_callback=progress_callback,
|
|
1190
|
+
)
|
|
1191
|
+
|
|
1192
|
+
# Create text processor if needed
|
|
1193
|
+
if text_processor is None and max_transcript_tokens is not None:
|
|
1194
|
+
text_processor = TextProcessor(
|
|
1195
|
+
max_tokens=max_transcript_tokens,
|
|
1196
|
+
strategy=cast(Literal["simple", "map_reduce", "sequential"], truncation_strategy),
|
|
1197
|
+
)
|
|
1198
|
+
def search_youtube(
|
|
1199
|
+
query: str,
|
|
1200
|
+
max_results: int = 10,
|
|
1201
|
+
duration: str | None = None,
|
|
1202
|
+
upload_date: str | None = None,
|
|
1203
|
+
sort_by: str | None = None,
|
|
1204
|
+
) -> SearchResults:
|
|
1205
|
+
"""Search YouTube for videos matching the query.
|
|
1206
|
+
|
|
1207
|
+
Args:
|
|
1208
|
+
query: Search query string
|
|
1209
|
+
max_results: Maximum number of results to return
|
|
1210
|
+
duration: Filter by video duration - "short" (< 4 min) or "long" (> 20 min). Omit for any duration.
|
|
1211
|
+
upload_date: Filter by upload date - "hour", "today", "week", "month", or "year". Omit for any date.
|
|
1212
|
+
sort_by: Sort results by - "relevance" (default), "date", "views", or "rating". Omit for relevance.
|
|
1213
|
+
|
|
1214
|
+
Returns:
|
|
1215
|
+
SearchResults object containing video information
|
|
1216
|
+
"""
|
|
1217
|
+
return searcher.search(
|
|
1218
|
+
query,
|
|
1219
|
+
max_results=max_results,
|
|
1220
|
+
duration=duration,
|
|
1221
|
+
upload_date=upload_date,
|
|
1222
|
+
sort_by=sort_by,
|
|
1223
|
+
)
|
|
1224
|
+
def get_video_transcript(video_url_or_id: str) -> str:
|
|
1225
|
+
"""Get the transcript for a YouTube video.
|
|
1226
|
+
|
|
1227
|
+
This function returns the transcript text, automatically handling
|
|
1228
|
+
long transcripts according to the configured truncation strategy.
|
|
1229
|
+
|
|
1230
|
+
If you get throttling or rate-limit errors, call set_transcript_delay
|
|
1231
|
+
to increase the wait time between requests before retrying.
|
|
1232
|
+
|
|
1233
|
+
Args:
|
|
1234
|
+
video_url_or_id: YouTube video URL or video ID
|
|
1235
|
+
|
|
1236
|
+
Returns:
|
|
1237
|
+
Transcript text (truncated if configured). If the transcript
|
|
1238
|
+
cannot be fetched, returns either a `TRANSCRIPT_RATE_LIMITED`
|
|
1239
|
+
or `TRANSCRIPT_UNAVAILABLE` message describing the failure so
|
|
1240
|
+
the agent can continue.
|
|
1241
|
+
"""
|
|
1242
|
+
try:
|
|
1243
|
+
transcript_obj = searcher.get_transcript(video_url_or_id)
|
|
1244
|
+
except Exception as exc:
|
|
1245
|
+
if _is_transcript_rate_limited(exc):
|
|
1246
|
+
return _format_transcript_rate_limited_message(
|
|
1247
|
+
video_url_or_id,
|
|
1248
|
+
exc,
|
|
1249
|
+
)
|
|
1250
|
+
return _format_transcript_unavailable_message(video_url_or_id, exc)
|
|
1251
|
+
|
|
1252
|
+
full_text = transcript_obj.get_full_text()
|
|
1253
|
+
|
|
1254
|
+
# Apply text processing if configured
|
|
1255
|
+
if text_processor is not None:
|
|
1256
|
+
full_text = text_processor.process(full_text)
|
|
1257
|
+
|
|
1258
|
+
return full_text
|
|
1259
|
+
def get_video_metadata(video_url_or_id: str) -> VideoMetadata:
|
|
1260
|
+
"""Get metadata for a YouTube video.
|
|
1261
|
+
|
|
1262
|
+
Args:
|
|
1263
|
+
video_url_or_id: YouTube video URL or video ID
|
|
1264
|
+
|
|
1265
|
+
Returns:
|
|
1266
|
+
VideoMetadata object with video information
|
|
1267
|
+
"""
|
|
1268
|
+
return searcher.get_video_metadata(video_url_or_id)
|
|
1269
|
+
def set_transcript_delay(delay_seconds: float) -> str:
|
|
1270
|
+
"""Set the delay between transcript requests to avoid throttling.
|
|
1271
|
+
|
|
1272
|
+
Call this tool to adjust the wait time between transcript fetches.
|
|
1273
|
+
Start with a low value (e.g. 0.5) and increase if you encounter
|
|
1274
|
+
rate-limit or throttling errors (e.g. to 3.0, 5.0, or 10.0).
|
|
1275
|
+
|
|
1276
|
+
Args:
|
|
1277
|
+
delay_seconds: Seconds to wait between transcript requests (0.0 to 30.0)
|
|
1278
|
+
|
|
1279
|
+
Returns:
|
|
1280
|
+
Confirmation message with the new delay
|
|
1281
|
+
"""
|
|
1282
|
+
clamped = max(0.0, min(30.0, delay_seconds))
|
|
1283
|
+
searcher.rate_limit_delay = clamped
|
|
1284
|
+
active_rate_limit_error = searcher._get_active_transcript_rate_limit_error()
|
|
1285
|
+
if active_rate_limit_error is not None:
|
|
1286
|
+
return (
|
|
1287
|
+
f"Transcript delay set to {clamped:.1f}s between requests. "
|
|
1288
|
+
f"Current transcript block still active: {active_rate_limit_error}"
|
|
1289
|
+
)
|
|
1290
|
+
return f"Transcript delay set to {clamped:.1f}s between requests."
|
|
1291
|
+
|
|
1292
|
+
return ToolRegistry.from_mapping({
|
|
1293
|
+
"search_youtube": search_youtube,
|
|
1294
|
+
"get_video_transcript": get_video_transcript,
|
|
1295
|
+
"get_video_metadata": get_video_metadata,
|
|
1296
|
+
"set_transcript_delay": set_transcript_delay,
|
|
1297
|
+
})
|