threads_api/
lib.rs

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
14/// Reverse engineered API client for Instagram's Threads app.
15pub struct Threads {
16	client: reqwest::Client,
17}
18
19impl Threads {
20	/// Create a new instance of the API.
21	#[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	/// Get a user's profile.
33	///
34	/// # Arguments
35	///
36	/// * `user_id` - The user's ID.
37	///
38	/// # Errors
39	///
40	/// Returns an error if the API request fails.
41	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	/// Get a list of a user's posts.
50	///
51	/// # Arguments
52	///
53	/// * `user_id` - The user's ID.
54	///
55	/// # Errors
56	///
57	/// Returns an error if the API request fails.
58	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	/// Get a list of a user's replies.
73	///
74	/// # Arguments
75	///
76	/// * `user_id` - The user's ID.
77	///
78	/// # Errors
79	///
80	/// Returns an error if the API request fails.
81	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	/// Get a post's data.
96	///
97	/// # Arguments
98	///
99	/// * `post_id` - The post's ID.
100	///
101	/// # Errors
102	///
103	/// Returns an error if the API request fails.
104	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	/// Get a list of users who liked a post.
125	///
126	/// # Arguments
127	///
128	/// * `post_id` - The post's ID.
129	///
130	/// # Errors
131	///
132	/// Returns an error if the API request fails.
133	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}