1#![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
2
3use map_macro::hash_map;
4use reqwest::ClientBuilder;
5use serde::de::DeserializeOwned;
6use serde_json::{json, Value};
7use types::{
8 internal::{LikersResponse, ProfileResponse, Response, ThreadResponse, ThreadsResponse},
9 PostResponse, Profile, ProfileDetail, Thread,
10};
11
12pub mod types;
13
14pub struct Threads {
16 client: reqwest::Client,
17}
18
19impl Threads {
20 #[must_use]
22 #[allow(clippy::missing_panics_doc)]
23 pub fn new() -> Self {
24 let client = ClientBuilder::new()
25 .user_agent("threads-api")
26 .build()
27 .unwrap();
28
29 Self { client }
30 }
31
32 pub async fn profile(&self, user_id: &str) -> Result<Profile, Error> {
42 let response = self
43 .get::<Response<ProfileResponse>>("23996318473300828", json!({ "userID": user_id }))
44 .await?;
45
46 Ok(response.data.user_data.user)
47 }
48
49 pub async fn posts(&self, user_id: &str) -> Result<Vec<Thread>, Error> {
59 let response = self
60 .get::<Response<ThreadsResponse>>("6232751443445612", json!({ "userID": user_id }))
61 .await?;
62
63 Ok(response
64 .data
65 .media_data
66 .threads
67 .into_iter()
68 .map(Into::into)
69 .collect())
70 }
71
72 pub async fn replies(&self, user_id: &str) -> Result<Vec<Thread>, Error> {
82 let response = self
83 .get::<Response<ThreadsResponse>>("6307072669391286", json!({ "userID": user_id }))
84 .await?;
85
86 Ok(response
87 .data
88 .media_data
89 .threads
90 .into_iter()
91 .map(Into::into)
92 .collect())
93 }
94
95 pub async fn post(&self, post_id: &str) -> Result<PostResponse, Error> {
105 let response = self
106 .get::<Response<Response<ThreadResponse>>>(
107 "5587632691339264",
108 json!({ "postID": post_id }),
109 )
110 .await?;
111
112 Ok(PostResponse {
113 post: response.data.data.containing_thread.into(),
114 replies: response
115 .data
116 .data
117 .reply_threads
118 .into_iter()
119 .map(Into::into)
120 .collect(),
121 })
122 }
123
124 pub async fn likes(&self, post_id: &str) -> Result<Vec<ProfileDetail>, Error> {
134 let response = self
135 .get::<Response<LikersResponse>>("9360915773983802", json!({ "mediaID": post_id }))
136 .await?;
137
138 Ok(response.data.likers.users)
139 }
140
141 async fn get<T: DeserializeOwned>(&self, doc_id: &str, variables: Value) -> Result<T, Error> {
142 let response = self
143 .client
144 .post("https://www.threads.net/api/graphql")
145 .header("x-ig-app-id", "238260118697367")
146 .form(&hash_map! {
147 "doc_id" => doc_id,
148 "variables" => &variables.to_string(),
149 })
150 .send()
151 .await?
152 .error_for_status()?;
153
154 Ok(response.json::<T>().await?)
155 }
156}
157
158impl Default for Threads {
159 fn default() -> Self {
160 Self::new()
161 }
162}
163
164#[derive(Debug, thiserror::Error)]
165pub enum Error {
166 #[error("{0}")]
167 Reqwest(#[from] reqwest::Error),
168
169 #[error("{0}")]
170 Serde(#[from] serde_json::Error),
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 #[tokio::test(flavor = "multi_thread")]
178 async fn can_get_zuck_profile() {
179 let threads = Threads::default();
180 let profile = threads.profile("314216").await.unwrap();
181
182 assert_eq!(profile.username, "zuck");
183 assert_eq!(profile.full_name, "Mark Zuckerberg");
184 }
185
186 #[tokio::test(flavor = "multi_thread")]
187 async fn can_get_zuck_posts() {
188 let threads = Threads::default();
189 let posts = threads.posts("314216").await.unwrap();
190
191 let first_thread = posts.last().unwrap();
192
193 assert_eq!(first_thread.id, "3138977881796614961");
194 assert_eq!(
195 first_thread.items[0].text,
196 "Let's do this. Welcome to Threads. 🔥"
197 );
198 }
199
200 #[tokio::test(flavor = "multi_thread")]
201 async fn can_get_zuck_replies() {
202 let threads = Threads::default();
203 let posts = threads.replies("314216").await.unwrap();
204
205 let first_reply = posts.last().unwrap();
206
207 assert_eq!(
208 first_reply.items[1].text,
209 "We're only in the opening moments of the first round here..."
210 );
211 }
212
213 #[tokio::test(flavor = "multi_thread")]
214 async fn can_get_post_data() {
215 let threads = Threads::default();
216 let thread = threads.post("3138977881796614961").await.unwrap();
217
218 assert_eq!(thread.post.id, "3138977881796614961");
219 assert_eq!(
220 thread.post.items[0].text,
221 "Let's do this. Welcome to Threads. 🔥"
222 );
223 }
224
225 #[tokio::test(flavor = "multi_thread")]
226 async fn can_get_post_likes() {
227 let threads = Threads::default();
228 let likers = threads.likes("3138977881796614961").await.unwrap();
229
230 assert!(!likers.is_empty());
231 }
232}