@@ -15,6 +15,7 @@ use std::time::Duration;
1515use url:: Url ;
1616
1717const API_TIMEOUT : Duration = Duration :: from_secs ( 10 ) ;
18+ const MAX_TRANSCRIPT_CHARS : usize = 15_000 ;
1819
1920/// YouTube video fetcher
2021///
@@ -143,7 +144,8 @@ impl Fetcher for YouTubeFetcher {
143144 let author_url = oembed. as_ref ( ) . and_then ( |o| o. author_url . clone ( ) ) ;
144145
145146 // Attempt transcript extraction via timedtext API
146- let transcript = fetch_transcript ( & client, & ua_header, & video_id) . await ;
147+ let transcript =
148+ fetch_transcript ( & client, & ua_header, & video_id, options. max_body_size ) . await ;
147149
148150 let content = format_youtube_response (
149151 & title,
@@ -171,6 +173,7 @@ async fn fetch_transcript(
171173 client : & reqwest:: Client ,
172174 ua : & HeaderValue ,
173175 video_id : & str ,
176+ max_body_size : Option < usize > ,
174177) -> Option < String > {
175178 // Try the legacy timedtext API (auto-generated English captions)
176179 let timedtext_url = format ! (
@@ -190,6 +193,11 @@ async fn fetch_transcript(
190193 }
191194
192195 let xml = resp. text ( ) . await . ok ( ) ?;
196+ if let Some ( max_body_size) = max_body_size {
197+ if xml. len ( ) > max_body_size {
198+ return None ;
199+ }
200+ }
193201 if xml. is_empty ( ) || !xml. contains ( "<text" ) {
194202 return None ;
195203 }
@@ -280,8 +288,9 @@ fn format_youtube_response(
280288 if let Some ( transcript) = transcript {
281289 out. push_str ( "\n ## Transcript\n \n " ) ;
282290 // Truncate very long transcripts
283- if transcript. len ( ) > 15000 {
284- out. push_str ( & transcript[ ..15000 ] ) ;
291+ if transcript. len ( ) > MAX_TRANSCRIPT_CHARS {
292+ let truncated = safe_truncate_utf8 ( transcript, MAX_TRANSCRIPT_CHARS ) ;
293+ out. push_str ( truncated) ;
285294 out. push_str ( "\n \n *[Transcript truncated]*\n " ) ;
286295 } else {
287296 out. push_str ( transcript) ;
@@ -294,6 +303,24 @@ fn format_youtube_response(
294303 out
295304}
296305
306+ fn safe_truncate_utf8 ( input : & str , max_bytes : usize ) -> & str {
307+ if input. len ( ) <= max_bytes {
308+ return input;
309+ }
310+
311+ if input. is_char_boundary ( max_bytes) {
312+ return & input[ ..max_bytes] ;
313+ }
314+
315+ let idx = input
316+ . char_indices ( )
317+ . map ( |( i, _) | i)
318+ . take_while ( |& i| i < max_bytes)
319+ . last ( )
320+ . unwrap_or ( 0 ) ;
321+ & input[ ..idx]
322+ }
323+
297324#[ cfg( test) ]
298325mod tests {
299326 use super :: * ;
@@ -454,4 +481,12 @@ mod tests {
454481 assert_eq ! ( decode_xml_entities( "<tag>" ) , "<tag>" ) ;
455482 assert_eq ! ( decode_xml_entities( "it's" ) , "it's" ) ;
456483 }
484+
485+ #[ test]
486+ fn test_safe_truncate_utf8_multibyte_boundary ( ) {
487+ let input = format ! ( "{}érest" , "a" . repeat( 14_999 ) ) ;
488+ let truncated = safe_truncate_utf8 ( & input, 15_000 ) ;
489+ assert_eq ! ( truncated. len( ) , 14_999 ) ;
490+ assert ! ( truncated. is_char_boundary( truncated. len( ) ) ) ;
491+ }
457492}
0 commit comments