diff --git a/crates/x-media/src/site/twitter/auth.rs b/crates/x-media/src/site/twitter/auth.rs index 8cbe293..cfce069 100644 --- a/crates/x-media/src/site/twitter/auth.rs +++ b/crates/x-media/src/site/twitter/auth.rs @@ -149,7 +149,7 @@ pub async fn fetch(id: &str) -> Result { "missing tweet fields in GraphQL response", ))) })?; - Tweet::from_syndication_json(&syndication_shape.to_string()).map_err(FetchError::Json) + Tweet::from_syndication_value(syndication_shape).map_err(FetchError::Json) } /// Locates the tweet for `id` in a `TweetDetail` response and unwraps @@ -223,7 +223,7 @@ fn normalize_tweet_result(result: &Value) -> Result { } /// Maps a GraphQL `{core, legacy, ...}` tweet onto the syndication JSON -/// shape [`Tweet::from_syndication_json`] parses, so the existing text / +/// shape [`Tweet::from_syndication_value`] parses, so the existing text / /// media handling (t.co expansion, `name=orig`, mp4 variant) is reused. fn to_syndication_shape(tweet: &Value) -> Option { let legacy = tweet.get("legacy")?; @@ -308,7 +308,7 @@ mod tests { let json = conversation(tweet_result()); let result = parse_tweet_result(&json, "2083868672721039569").unwrap(); let shape = to_syndication_shape(&result).unwrap(); - let tweet = Tweet::from_syndication_json(&shape.to_string()).unwrap(); + let tweet = Tweet::from_syndication_value(shape).unwrap(); let fetched: crate::site::Fetched = tweet.into(); assert!(fetched.sensitive); diff --git a/crates/x-media/src/site/twitter/interface.rs b/crates/x-media/src/site/twitter/interface.rs index 87e5852..f54327a 100644 --- a/crates/x-media/src/site/twitter/interface.rs +++ b/crates/x-media/src/site/twitter/interface.rs @@ -112,13 +112,16 @@ pub async fn fetch(id: &str) -> Result { }; } let text = response.text().await?; - // Classify before parsing the tweet (see [`parse_syndication_body`]). - parse_syndication_body(&text)?; - Tweet::from_syndication_json(&text).map_err(FetchError::Json) + // Classify before building the tweet (see [`parse_syndication_body`]), and + // build it from the value that classification already parsed: this used to + // scan and allocate the whole body twice. + let body = parse_syndication_body(&text)?; + Tweet::from_syndication_value(body).map_err(FetchError::Json) } -/// Parses and classifies a syndication response body. `Ok` means the body is -/// a real tweet payload; `Err` carries the permanent error class: +/// Parses and classifies a syndication response body. `Ok` carries the parsed +/// body on for the caller to build the tweet from — the same value, so the +/// text is never parsed twice; `Err` carries the permanent error class: /// - `NotFound`: an `errors` array (deleted/blocked) or a `TweetTombstone` /// **with a reason** — "This Post was deleted by the Post author." / /// "This Post is from a suspended account." (the tweet is gone). @@ -227,8 +230,13 @@ impl Tweet { ) } - pub fn from_syndication_json(raw_json: &str) -> Result { - let json: model::SyndicationTweet = serde_json::from_str(raw_json)?; + /// Builds a tweet from an already-parsed syndication body. Takes the value + /// rather than JSON text so a caller that had to parse it anyway (the + /// fetch path classifies the raw shape; the auth fallback builds the shape + /// itself) does not pay for a second scan — `from_value` moves the strings + /// out instead. + pub fn from_syndication_value(body: serde_json::Value) -> Result { + let json: model::SyndicationTweet = serde_json::from_value(body)?; let id = json.id_str; // Expand the user's t.co short links to their real destinations and // strip the appended media short link, mirroring FxEmbed's linkFixer @@ -430,7 +438,7 @@ mod tests { "entities": { "urls": [] }, "mediaDetails": [] }); - let tweet = Tweet::from_syndication_json(&raw.to_string()).unwrap(); + let tweet = Tweet::from_syndication_value(raw).unwrap(); // The appended media short link is stripped, then entities decoded. assert_eq!(tweet.text, ">^ω^< & more 'quoted'"); assert_eq!(tweet.author, "O'Brien"); @@ -489,7 +497,7 @@ mod tests { } } ])); - let tweet = Tweet::from_syndication_json(&raw.to_string()).unwrap(); + let tweet = Tweet::from_syndication_value(raw).unwrap(); let fetched: Fetched = tweet.into(); assert_eq!( fetched.source_url, @@ -535,7 +543,7 @@ mod tests { } } ])); - let tweet = Tweet::from_syndication_json(&raw.to_string()).unwrap(); + let tweet = Tweet::from_syndication_value(raw).unwrap(); assert!(matches!(&tweet.media[0], Media::Animated { .. })); } @@ -562,7 +570,7 @@ mod tests { "user": { "name": "N", "screen_name": "h" }, "mediaDetails": [] }); - let tweet = Tweet::from_syndication_json(&raw.to_string()).unwrap(); + let tweet = Tweet::from_syndication_value(raw).unwrap(); assert_eq!(tweet.text, visible, "left a partial link in {text:?}"); assert!(!tweet.caption().contains("t.co"), "{text:?}"); } @@ -587,7 +595,7 @@ mod tests { }, "mediaDetails": [] }); - let tweet = Tweet::from_syndication_json(&raw.to_string()).unwrap(); + let tweet = Tweet::from_syndication_value(raw).unwrap(); assert_eq!( tweet.text, "Test Tweet with @mentionThis $twtr http://bit.ly/2pUk4be #hashtag" @@ -606,7 +614,7 @@ mod tests { "user": { "name": "N", "screen_name": "h" }, "mediaDetails": [] }); - let tweet = Tweet::from_syndication_json(&raw.to_string()).unwrap(); + let tweet = Tweet::from_syndication_value(raw).unwrap(); assert_eq!(tweet.text, "check #tag"); } @@ -629,7 +637,7 @@ mod tests { }, "mediaDetails": [] }); - let tweet = Tweet::from_syndication_json(&raw.to_string()).unwrap(); + let tweet = Tweet::from_syndication_value(raw).unwrap(); assert_eq!(tweet.text, "see for context"); assert!(!tweet.caption().contains("t.co")); }