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.
Files changed (130) hide show
  1. ellements/__init__.py +57 -0
  2. ellements/agents/__init__.py +45 -0
  3. ellements/agents/backend.py +100 -0
  4. ellements/agents/builder.py +303 -0
  5. ellements/agents/claude_backend.py +200 -0
  6. ellements/agents/controller.py +237 -0
  7. ellements/agents/openai_backend.py +187 -0
  8. ellements/agents/py.typed +0 -0
  9. ellements/agents/runner.py +358 -0
  10. ellements/agents/tools.py +30 -0
  11. ellements/benchmarking/__init__.py +52 -0
  12. ellements/benchmarking/harness.py +342 -0
  13. ellements/benchmarking/py.typed +0 -0
  14. ellements/benchmarking/results.py +173 -0
  15. ellements/cli/__init__.py +80 -0
  16. ellements/cli/adapters.py +73 -0
  17. ellements/cli/agent_tui.py +1178 -0
  18. ellements/cli/components.py +411 -0
  19. ellements/cli/printer.py +229 -0
  20. ellements/cli/py.typed +0 -0
  21. ellements/core/__init__.py +112 -0
  22. ellements/core/async_utils.py +42 -0
  23. ellements/core/budgeting/__init__.py +43 -0
  24. ellements/core/budgeting/client.py +276 -0
  25. ellements/core/budgeting/protocol.py +51 -0
  26. ellements/core/budgeting/trackers.py +177 -0
  27. ellements/core/caching/__init__.py +51 -0
  28. ellements/core/caching/cache.py +73 -0
  29. ellements/core/caching/client.py +300 -0
  30. ellements/core/caching/disk.py +128 -0
  31. ellements/core/caching/keys.py +97 -0
  32. ellements/core/caching/memory.py +104 -0
  33. ellements/core/chunking.py +262 -0
  34. ellements/core/config.py +180 -0
  35. ellements/core/exceptions.py +145 -0
  36. ellements/core/llm/__init__.py +46 -0
  37. ellements/core/llm/client.py +1124 -0
  38. ellements/core/llm/images.py +226 -0
  39. ellements/core/llm/messages.py +202 -0
  40. ellements/core/llm/model_params.py +66 -0
  41. ellements/core/llm/protocol.py +146 -0
  42. ellements/core/llm/requests.py +100 -0
  43. ellements/core/llm/structured.py +91 -0
  44. ellements/core/llm/wrapper.py +79 -0
  45. ellements/core/observability/__init__.py +39 -0
  46. ellements/core/observability/events.py +169 -0
  47. ellements/core/observability/jsonl_logger.py +244 -0
  48. ellements/core/observability/markdown_formatter.py +197 -0
  49. ellements/core/observability/observer.py +56 -0
  50. ellements/core/prompting/__init__.py +14 -0
  51. ellements/core/prompting/context.py +185 -0
  52. ellements/core/prompting/guideline.py +133 -0
  53. ellements/core/prompting/persona.py +267 -0
  54. ellements/core/prompting/sources.py +92 -0
  55. ellements/core/py.typed +0 -0
  56. ellements/core/rate_limit/__init__.py +44 -0
  57. ellements/core/rate_limit/bucket.py +85 -0
  58. ellements/core/rate_limit/client.py +216 -0
  59. ellements/core/rate_limit/protocol.py +27 -0
  60. ellements/core/templating.py +126 -0
  61. ellements/core/tools/__init__.py +51 -0
  62. ellements/core/tools/dialects.py +136 -0
  63. ellements/core/tools/executor.py +48 -0
  64. ellements/core/tools/protocol.py +36 -0
  65. ellements/core/tools/records.py +28 -0
  66. ellements/core/tools/registry.py +205 -0
  67. ellements/core/tools/schemas.py +80 -0
  68. ellements/core/tools/simple.py +119 -0
  69. ellements/core/tools/spec.py +33 -0
  70. ellements/domain_specific/__init__.py +7 -0
  71. ellements/domain_specific/finance/__init__.py +188 -0
  72. ellements/domain_specific/finance/calculations.py +837 -0
  73. ellements/domain_specific/finance/charts.py +426 -0
  74. ellements/domain_specific/finance/portfolio.py +129 -0
  75. ellements/domain_specific/finance/quant_analysis.py +279 -0
  76. ellements/domain_specific/finance/risk.py +362 -0
  77. ellements/domain_specific/finance/technical_indicators.py +241 -0
  78. ellements/domain_specific/finance/tools.py +483 -0
  79. ellements/domain_specific/finance/valuation.py +312 -0
  80. ellements/domain_specific/finance/yahoo_finance.py +1523 -0
  81. ellements/domain_specific/finance/yahoo_finance_models.py +321 -0
  82. ellements/domain_specific/py.typed +0 -0
  83. ellements/execution/__init__.py +56 -0
  84. ellements/execution/callbacks.py +149 -0
  85. ellements/execution/catalog.py +70 -0
  86. ellements/execution/collaborative.py +191 -0
  87. ellements/execution/config.py +135 -0
  88. ellements/execution/py.typed +0 -0
  89. ellements/execution/reflection.py +156 -0
  90. ellements/execution/self_consistency.py +189 -0
  91. ellements/execution/single_call.py +48 -0
  92. ellements/execution/strategies.py +237 -0
  93. ellements/execution/tree_of_thought.py +541 -0
  94. ellements/fslm/__init__.py +108 -0
  95. ellements/fslm/builtins.py +103 -0
  96. ellements/fslm/cli.py +199 -0
  97. ellements/fslm/context.py +50 -0
  98. ellements/fslm/definition.py +163 -0
  99. ellements/fslm/det.py +173 -0
  100. ellements/fslm/dsl.py +458 -0
  101. ellements/fslm/errors.py +38 -0
  102. ellements/fslm/evaluators.py +141 -0
  103. ellements/fslm/kernel.py +603 -0
  104. ellements/fslm/loading.py +123 -0
  105. ellements/fslm/models.py +623 -0
  106. ellements/fslm/nl.py +87 -0
  107. ellements/fslm/observers.py +107 -0
  108. ellements/fslm/persistence.py +72 -0
  109. ellements/fslm/py.typed +0 -0
  110. ellements/fslm/rendering.py +551 -0
  111. ellements/fslm/visualization.py +25 -0
  112. ellements/reporting/__init__.py +12 -0
  113. ellements/reporting/charts.py +364 -0
  114. ellements/reporting/html_generation.py +132 -0
  115. ellements/reporting/py.typed +0 -0
  116. ellements/reporting/visualization.py +73 -0
  117. ellements/standard_tools/__init__.py +22 -0
  118. ellements/standard_tools/py.typed +0 -0
  119. ellements/standard_tools/terminal.py +205 -0
  120. ellements/standard_tools/web/__init__.py +37 -0
  121. ellements/standard_tools/web/browser_viewer.py +214 -0
  122. ellements/standard_tools/web/crawler.py +454 -0
  123. ellements/standard_tools/web/search.py +399 -0
  124. ellements/standard_tools/web/youtube.py +1297 -0
  125. ellements-0.2.0.dist-info/METADATA +368 -0
  126. ellements-0.2.0.dist-info/RECORD +130 -0
  127. ellements-0.2.0.dist-info/WHEEL +5 -0
  128. ellements-0.2.0.dist-info/entry_points.txt +2 -0
  129. ellements-0.2.0.dist-info/licenses/LICENSE +42 -0
  130. 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
+ })