perf: parse a twitter syndication body once

`fetch` classified the response with `parse_syndication_body` — which parses
the whole body into a `serde_json::Value` — and then threw that value away
and re-parsed the same text into a `SyndicationTweet`. Two full JSON scans
and two allocations of every string in the body (text, entities, media
details) per tweet.

`Tweet::from_syndication_value` takes the value classification already
built and deserializes from that: `from_value` moves the strings out of the
tree instead of allocating copies, so the response text is scanned once.
The auth fallback had the mirror image of the same waste — it built the
syndication shape as a `Value` and then serialized it back to a string for
a parse — and the 8 test call sites lose their `.to_string()` with it.

Verified live: `cargo test -p x-media -- --ignored live` (14 passed),
including the 5 twitter fetches that exercise this path.

`cargo fmt --check`, `cargo clippy --workspace --all-targets --locked -- -D
warnings` and `cargo test --workspace --locked` clean.
This commit is contained in:
2026-09-21 12:49:26 +08:00
parent a1c452f19d
commit e31bf92df7
2 changed files with 25 additions and 17 deletions
+3 -3
View File
@@ -149,7 +149,7 @@ pub async fn fetch(id: &str) -> Result<Tweet, FetchError> {
"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<Value, FetchError> {
}
/// 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<Value> {
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);
+22 -14
View File
@@ -112,13 +112,16 @@ pub async fn fetch(id: &str) -> Result<Tweet, FetchError> {
};
}
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<Self, serde_json::Error> {
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<Self, serde_json::Error> {
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"));
}