├── .gitignore ├── rustfmt.toml ├── Cargo.toml ├── README.md ├── LICENSE └── src ├── types.rs └── lib.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /Cargo.lock 3 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | tab_spaces = 4 2 | hard_tabs = true 3 | edition = "2021" 4 | use_try_shorthand = true 5 | imports_granularity = "crate" 6 | use_field_init_shorthand = true 7 | condense_wildcard_suffixes = true 8 | match_block_trailing_comma = true 9 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | license = "MIT" 3 | edition = "2021" 4 | version = "0.1.2" 5 | readme = "README.md" 6 | name = "threads-api" 7 | categories = ["api-bindings"] 8 | repository = "https://github.com/m1guelpf/threads-api" 9 | description = "Reverse engineered API client for Instagram's Threads app." 10 | authors = ["Miguel Piedrafita "] 11 | keywords = ["instagram", "threads", "reverse-engineering", "instagram-api", "threads-api"] 12 | 13 | [dependencies] 14 | map-macro = "0.2.6" 15 | thiserror = "1.0.41" 16 | serde_json = "1.0.100" 17 | reqwest = { version = "0.11.18", features = ["multipart", "json"] } 18 | serde = { version = "1.0.166", features = ["derive"] } 19 | 20 | [dev-dependencies] 21 | tokio = { version = "1.29.1", features = ["macros", "rt-multi-thread"] } 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Instagram Threads API 2 | 3 | [![Crates.io](https://img.shields.io/crates/v/threads-api.svg)](https://crates.io/crates/threads-api) 4 | [![Docs.rs](https://docs.rs/threads-api/badge.svg)](https://docs.rs/threads-api) 5 | [![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) 6 | 7 | > Unofficial, Reverse-Engineered Rust client for Instagram's [Threads](https://threads.net). 8 | 9 | ## Usage 10 | 11 | ```rust 12 | use threads_api::Threads; 13 | 14 | let client = Threads::new(); 15 | 16 | let user = client.profile(user_id).await?; 17 | let posts = client.posts(user_id).await?; 18 | let posts = client.replies(user_id).await?; 19 | ``` 20 | 21 | ## 📌 Roadmap 22 | 23 | - [x] Get user profile 24 | - [x] Get user posts 25 | - [x] Get user replies 26 | - [x] Get post replies 27 | - [x] Get post likes 28 | - [ ] Authentication 29 | - [ ] Post a thread 30 | - [ ] Post a reply 31 | - [ ] Update profile details 32 | - [ ] Follow a user 33 | - [ ] Unfollow a user 34 | 35 | ## License 36 | 37 | This project is open-sourced under the MIT license. See [the License file](LICENSE) for more information. 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Miguel Piedrafita 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/types.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | pub(crate) mod internal { 4 | use super::{Card, Media, Profile, ProfileDetail}; 5 | 6 | #[derive(serde::Deserialize)] 7 | pub struct Response { 8 | pub data: T, 9 | } 10 | 11 | #[derive(serde::Deserialize)] 12 | pub struct ProfileResponse { 13 | #[serde(rename = "userData")] 14 | pub user_data: UserFragment, 15 | } 16 | 17 | #[derive(serde::Deserialize)] 18 | pub struct ThreadsResponse { 19 | #[serde(rename = "mediaData")] 20 | pub media_data: ThreadsFragment, 21 | } 22 | 23 | #[derive(serde::Deserialize)] 24 | pub struct ThreadResponse { 25 | pub containing_thread: Thread, 26 | pub reply_threads: Vec, 27 | } 28 | 29 | #[derive(serde::Deserialize)] 30 | pub struct LikersResponse { 31 | pub likers: Likers, 32 | } 33 | 34 | #[derive(serde::Deserialize)] 35 | pub struct Likers { 36 | pub users: Vec, 37 | } 38 | 39 | #[derive(serde::Deserialize)] 40 | pub struct UserFragment { 41 | pub user: Profile, 42 | } 43 | 44 | #[derive(serde::Deserialize)] 45 | pub struct ThreadsFragment { 46 | pub threads: Vec, 47 | } 48 | 49 | #[derive(serde::Deserialize)] 50 | pub struct Thread { 51 | pub id: String, 52 | pub thread_items: Vec, 53 | } 54 | 55 | #[derive(serde::Deserialize)] 56 | pub struct ThreadItem { 57 | pub post: Post, 58 | } 59 | 60 | #[derive(serde::Deserialize)] 61 | pub struct Post { 62 | pub user: ProfileDetail, 63 | #[serde(rename = "image_versions2")] 64 | pub images: ImageVersions, 65 | pub original_width: u32, 66 | pub original_height: u32, 67 | pub caption: Caption, 68 | pub taken_at: u64, 69 | pub like_count: u32, 70 | pub text_post_app_info: PostMeta, 71 | } 72 | 73 | #[derive(serde::Deserialize)] 74 | pub struct PostMeta { 75 | pub direct_reply_count: Option, 76 | pub link_preview_attachment: Option, 77 | } 78 | 79 | #[derive(serde::Deserialize)] 80 | pub struct Caption { 81 | pub text: String, 82 | } 83 | 84 | #[derive(serde::Deserialize)] 85 | pub struct ImageVersions { 86 | pub candidates: Vec, 87 | } 88 | } 89 | 90 | /// Contains the minimum required information to display a profile. 91 | #[derive(serde::Deserialize)] 92 | pub struct ProfileDetail { 93 | pub profile_pic_url: String, 94 | pub username: String, 95 | pub is_verified: bool, 96 | #[serde(rename = "pk")] 97 | pub id: String, 98 | } 99 | 100 | /// Contains all the information available about a profile. 101 | #[derive(Deserialize)] 102 | pub struct Profile { 103 | #[serde(rename = "pk")] 104 | pub id: String, 105 | pub is_private: bool, 106 | pub profile_pic_url: String, 107 | pub username: String, 108 | pub is_verified: bool, 109 | pub biography: String, 110 | pub follower_count: u32, 111 | pub bio_links: Vec, 112 | pub full_name: String, 113 | pub hd_profile_pic_versions: Vec, 114 | } 115 | 116 | /// A link to an external website. 117 | #[derive(Deserialize)] 118 | pub struct Link { 119 | pub url: String, 120 | } 121 | 122 | /// A media item. 123 | #[derive(Deserialize)] 124 | pub struct Media { 125 | pub url: String, 126 | pub width: u32, 127 | pub height: u32, 128 | } 129 | 130 | #[derive(Deserialize)] 131 | pub struct PostResponse { 132 | pub post: Thread, 133 | pub replies: Vec, 134 | } 135 | 136 | #[derive(Deserialize)] 137 | pub struct Card { 138 | pub url: String, 139 | pub title: String, 140 | pub image_url: String, 141 | pub display_url: String, 142 | pub favicon_url: Option, 143 | } 144 | 145 | /// A thread of posts. 146 | #[derive(Deserialize)] 147 | pub struct Thread { 148 | pub id: String, 149 | pub items: Vec, 150 | } 151 | 152 | impl From for Thread { 153 | fn from(value: internal::Thread) -> Self { 154 | Self { 155 | id: value.id, 156 | items: value 157 | .thread_items 158 | .into_iter() 159 | .map(|i| i.post.into()) 160 | .collect(), 161 | } 162 | } 163 | } 164 | 165 | /// A post in a thread. 166 | #[derive(Deserialize)] 167 | pub struct ThreadItem { 168 | pub likes: u32, 169 | pub text: String, 170 | pub published_at: u64, 171 | pub images: Vec, 172 | pub user: ProfileDetail, 173 | pub replies: Option, 174 | pub link_card: Option, 175 | } 176 | 177 | impl From for ThreadItem { 178 | fn from(thread: internal::Post) -> Self { 179 | Self { 180 | user: thread.user, 181 | likes: thread.like_count, 182 | text: thread.caption.text, 183 | published_at: thread.taken_at, 184 | images: thread.images.candidates, 185 | replies: thread.text_post_app_info.direct_reply_count, 186 | link_card: thread.text_post_app_info.link_preview_attachment, 187 | } 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)] 2 | 3 | use map_macro::hash_map; 4 | use reqwest::ClientBuilder; 5 | use serde::de::DeserializeOwned; 6 | use serde_json::{json, Value}; 7 | use types::{ 8 | internal::{LikersResponse, ProfileResponse, Response, ThreadResponse, ThreadsResponse}, 9 | PostResponse, Profile, ProfileDetail, Thread, 10 | }; 11 | 12 | pub mod types; 13 | 14 | /// Reverse engineered API client for Instagram's Threads app. 15 | pub struct Threads { 16 | client: reqwest::Client, 17 | } 18 | 19 | impl 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 { 42 | let response = self 43 | .get::>("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, Error> { 59 | let response = self 60 | .get::>("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, Error> { 82 | let response = self 83 | .get::>("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 { 105 | let response = self 106 | .get::>>( 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, Error> { 134 | let response = self 135 | .get::>("9360915773983802", json!({ "mediaID": post_id })) 136 | .await?; 137 | 138 | Ok(response.data.likers.users) 139 | } 140 | 141 | async fn get(&self, doc_id: &str, variables: Value) -> Result { 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::().await?) 155 | } 156 | } 157 | 158 | impl Default for Threads { 159 | fn default() -> Self { 160 | Self::new() 161 | } 162 | } 163 | 164 | #[derive(Debug, thiserror::Error)] 165 | pub 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)] 174 | mod 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 | } 233 | --------------------------------------------------------------------------------