├── .github ├── c_d_scraping.jpg ├── cover.jpg ├── emojis │ ├── package.png │ ├── pushpin.png │ ├── rocket.png │ └── sewing-needle.png ├── labtocat.png ├── logo.jpg ├── rich-threads.png └── text-threads.jpg ├── .gitignore ├── .imgbotconfig ├── .prettierignore ├── .prettierrc.js ├── .vscode ├── extensions.json └── settings.json ├── .yarn └── releases │ └── yarn-3.6.1.cjs ├── .yarnrc.yml ├── LICENSE ├── PRESERVED.md ├── README.md ├── fetch-dynamic-data └── index.deno.ts ├── package.json ├── threads-api ├── .swcrc ├── README.md ├── __test__ │ ├── .env.example │ ├── getPostIDfromThreadID.test.ts │ ├── getPostIDfromURL.test.ts │ ├── getThreadLikers.test.ts │ ├── getThreads.test.ts │ ├── getTimeline.test.ts │ ├── getToken.test.ts │ ├── getUserFollows.test.ts │ ├── getUserIDfromUsername.test.ts │ ├── getUserProfile.test.ts │ ├── getUserProfileReplies.test.ts │ ├── getUserProfileThreads.test.ts │ ├── interaction-follow-and-unfollow.test.ts │ ├── interaction-like-and-unlike.test.ts │ ├── login.test.ts │ ├── publish.test.ts │ ├── publishWithImage.test.ts │ ├── setup-tests.ts │ └── utils │ │ ├── constants.ts │ │ └── describeIf.ts ├── bin │ └── cli.js ├── jest.config.js ├── package.json ├── src │ ├── bloks-types.ts │ ├── cli.ts │ ├── constants.ts │ ├── dynamic-data.ts │ ├── error.ts │ ├── fetch.ts │ ├── index.ts │ ├── threads-api.ts │ ├── threads-types.ts │ └── types │ │ └── utils.ts └── tsconfig.json ├── threads-web-ui ├── .gitignore ├── app │ ├── [username] │ │ └── page.tsx │ ├── apps │ │ └── page.tsx │ ├── globals.css │ ├── layout.tsx │ ├── opengraph-image.jpg │ ├── page.tsx │ ├── post │ │ └── [threadOrPostID] │ │ │ └── page.tsx │ └── twitter-image.jpg ├── assets │ ├── app.png │ ├── instagram.svg │ ├── meta.svg │ ├── openai.svg │ ├── pirate-flag.png │ ├── robot.png │ └── twitter.svg ├── components.json ├── components │ ├── AnalyticsTracker.tsx │ ├── AppRegistryItem.tsx │ ├── Footer.tsx │ ├── Globe.tsx │ ├── NavigationBar.tsx │ ├── Twemoji.tsx │ └── UnaffiliatedBrands.tsx ├── data │ └── apps.ts ├── hooks │ └── useWindowSize.ts ├── lib │ ├── analytics.ts │ ├── api.ts │ └── utils.ts ├── next-env.d.ts ├── next.config.js ├── package.json ├── postcss.config.js ├── public │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── assets │ │ ├── apps │ │ │ ├── aperturs.jpg │ │ │ ├── b__polarbear.jpg │ │ │ ├── fortune_cookie_bot.jpg │ │ │ ├── hackernewsbot.jpg │ │ │ ├── instathreadsdown.jpg │ │ │ ├── mediathreadsnet.jpg │ │ │ ├── opttimeline.jpg │ │ │ ├── progressyearly.jpg │ │ │ ├── rethreads.png │ │ │ ├── splatoon3-ink.png │ │ │ ├── threadsdiscord.png │ │ │ ├── yearprog.jpg │ │ │ └── yearsprogress.jpg │ │ └── mesh-gradient.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ └── site.webmanifest ├── tailwind.config.js └── tsconfig.json └── yarn.lock /.github/c_d_scraping.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junhoyeo/threads-api/88d9e508702115cce902e6f92882d3da984f8158/.github/c_d_scraping.jpg -------------------------------------------------------------------------------- /.github/cover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junhoyeo/threads-api/88d9e508702115cce902e6f92882d3da984f8158/.github/cover.jpg -------------------------------------------------------------------------------- /.github/emojis/package.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junhoyeo/threads-api/88d9e508702115cce902e6f92882d3da984f8158/.github/emojis/package.png -------------------------------------------------------------------------------- /.github/emojis/pushpin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junhoyeo/threads-api/88d9e508702115cce902e6f92882d3da984f8158/.github/emojis/pushpin.png -------------------------------------------------------------------------------- /.github/emojis/rocket.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junhoyeo/threads-api/88d9e508702115cce902e6f92882d3da984f8158/.github/emojis/rocket.png -------------------------------------------------------------------------------- /.github/emojis/sewing-needle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junhoyeo/threads-api/88d9e508702115cce902e6f92882d3da984f8158/.github/emojis/sewing-needle.png -------------------------------------------------------------------------------- /.github/labtocat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junhoyeo/threads-api/88d9e508702115cce902e6f92882d3da984f8158/.github/labtocat.png -------------------------------------------------------------------------------- /.github/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junhoyeo/threads-api/88d9e508702115cce902e6f92882d3da984f8158/.github/logo.jpg -------------------------------------------------------------------------------- /.github/rich-threads.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junhoyeo/threads-api/88d9e508702115cce902e6f92882d3da984f8158/.github/rich-threads.png -------------------------------------------------------------------------------- /.github/text-threads.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junhoyeo/threads-api/88d9e508702115cce902e6f92882d3da984f8158/.github/text-threads.jpg -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # testing 9 | coverage 10 | 11 | # next.js 12 | .vercel 13 | .next 14 | 15 | out 16 | dist 17 | build 18 | *.tsbuildinfo 19 | 20 | # Yarn 21 | .yarn-integrity 22 | .yarn/cache 23 | .yarn/unplugged 24 | .yarn/build-state.yml 25 | .yarn/install-state.gz 26 | 27 | # misc 28 | .DS_Store 29 | *.pem 30 | 31 | # debug 32 | npm-debug.log* 33 | yarn-debug.log* 34 | yarn-error.log* 35 | .pnpm-debug.log* 36 | 37 | # local env files 38 | .env 39 | .env.local 40 | .env.development.local 41 | .env.test.local 42 | .env.production.local 43 | 44 | # turbo 45 | .turbo 46 | 47 | # venv 48 | venv 49 | 50 | # Python 51 | *.pyc 52 | __pycache__/ 53 | -------------------------------------------------------------------------------- /.imgbotconfig: -------------------------------------------------------------------------------- 1 | { 2 | "ignoredFiles": [".github/emojis/*"] 3 | } 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | 3 | .next 4 | .vercel 5 | 6 | # Yarn 7 | .yarn 8 | .pnp.cjs 9 | .pnp.loader.mjs 10 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | bracketSameLine: false, 4 | trailingComma: 'all', 5 | bracketSpacing: true, 6 | semi: true, 7 | printWidth: 110, 8 | 9 | // @trivago/prettier-plugin-sort-imports 10 | importOrder: [ 11 | '', 12 | '@/(assets|components|hooks|pages|jotai|styles|utils)/(.*)$', 13 | '@/(.*)$', 14 | '^[./](.*)$', 15 | ], 16 | importOrderSeparation: true, 17 | importOrderSortSpecifiers: true, 18 | }; 19 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "[javascript]": { 4 | "editor.defaultFormatter": "esbenp.prettier-vscode" 5 | }, 6 | "[typescript]": { 7 | "editor.defaultFormatter": "esbenp.prettier-vscode" 8 | }, 9 | "[typescriptreact]": { 10 | "editor.defaultFormatter": "esbenp.prettier-vscode" 11 | }, 12 | "[python]": { 13 | "editor.defaultFormatter": "ms-python.autopep8", 14 | }, 15 | "jest.autoRun": {}, 16 | "deno.enable": false, 17 | } -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | yarnPath: .yarn/releases/yarn-3.6.1.cjs 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Junho Yeo 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 | -------------------------------------------------------------------------------- /PRESERVED.md: -------------------------------------------------------------------------------- 1 | # [](https://github.com/junhoyeo) Threads API 2 | 3 | [![NPM](https://img.shields.io/npm/v/threads-api.svg?style=flat-square&labelColor=black)](https://www.npmjs.com/package/threads-api) [![MIT License](https://img.shields.io/badge/license-MIT-blue?style=flat-square&labelColor=black)](https://github.com/junhoyeo/threads-api/blob/main/LICENSE) [![Prettier Code Formatting](https://img.shields.io/badge/code_style-prettier-brightgreen.svg?style=flat-square&labelColor=black)](https://prettier.io) 4 | 5 | > Unofficial, Reverse-Engineered Node.js/TypeScript client for Meta's [Threads](https://threads.net). 6 | 7 | ## [](https://github.com/junhoyeo) `threads-api` in Action 8 | 9 |

10 | 11 | cover 12 | 13 |

14 | 15 | > ✨ The [App Registry](https://threads.junho.io/apps) is officially live! We invite you to explore it on our website at [threads.junho.io](https://threads.junho.io).
16 | > Modify [threads-web-ui/data/apps.ts](https://github.com/junhoyeo/threads-api/blob/main/threads-web-ui/data/apps.ts) to add your projects! 17 | 18 |

19 | 20 | cover 21 | 22 |

23 | 24 | ### 🚀 Usage (Read) 25 | 26 |
27 |

Read: Public

28 | 29 | ```ts 30 | import { ThreadsAPI } from 'threads-api'; 31 | 32 | // or in Deno 🦖: 33 | // import ThreadsAPI from "npm:threads-api"; 34 | 35 | const main = async () => { 36 | const threadsAPI = new ThreadsAPI(); 37 | 38 | const username = '_junhoyeo'; 39 | 40 | // 👤 Details for a specific user 41 | const userID = await threadsAPI.getUserIDfromUsername(username); 42 | if (!userID) { 43 | return; 44 | } 45 | const user = await threadsAPI.getUserProfile(userID); 46 | console.log(JSON.stringify(user)); 47 | const posts = await threadsAPI.getUserProfileThreads(userID); 48 | console.log(JSON.stringify(posts)); 49 | const replies = await threadsAPI.getUserProfileReplies(userID); 50 | console.log(JSON.stringify(replies)); 51 | 52 | // 📖 Details for a specific thread 53 | const postID = threadsAPI.getPostIDfromURL( 54 | 'https://www.threads.net/t/CuX_UYABrr7/?igshid=MzRlODBiNWFlZA==', 55 | ); 56 | // or use `threadsAPI.getPostIDfromThreadID('CuX_UYABrr7')` 57 | if (!postID) { 58 | return; 59 | } 60 | const post = await threadsAPI.getThreads(postID); 61 | console.log(JSON.stringify(post.containing_thread)); 62 | console.log(JSON.stringify(post.reply_threads)); 63 | 64 | const likers = await threadsAPI.getThreadLikers(postID); 65 | console.log(JSON.stringify(likers)); 66 | }; 67 | main(); 68 | ``` 69 | 70 |
71 | 72 | #### Read: Private(Auth Required) 73 | 74 | ##### 💡 Get User Profile (from v1.6.2) 75 | 76 | - `getUserProfile` but with auth 77 | 78 | ```ts 79 | const userID = '5438123050'; 80 | const { user } = await threadsAPI.getUserProfileLoggedIn(); 81 | console.log(JSON.stringify(user)); 82 | ``` 83 | 84 | ##### 💡 Get Timeline 85 | 86 | ```ts 87 | const { items: threads, next_max_id: cursor } = await threadsAPI.getTimeline(); 88 | console.log(JSON.stringify(threads)); 89 | ``` 90 | 91 | ##### 💡 Get Threads/Replies from a User (with pagination) 92 | 93 | ```ts 94 | const { threads, next_max_id: cursor } = await threadsAPI.getUserProfileThreadsLoggedIn(userID); 95 | console.log(JSON.stringify(threads)); 96 | ``` 97 | 98 | ```ts 99 | const { threads, next_max_id: cursor } = await threadsAPI.getUserProfileRepliesLoggedIn(userID); 100 | console.log(JSON.stringify(threads)); 101 | ``` 102 | 103 | ##### 💡 Get Followers/Followings of a User (with Pagination) 104 | 105 | ```ts 106 | const { users, next_max_id: cursor } = await threadsAPI.getUserFollowers(userID); 107 | console.log(JSON.stringify(users)); 108 | ``` 109 | 110 | ```ts 111 | const { users, next_max_id: cursor } = await threadsAPI.getUserFollowings(userID); 112 | console.log(JSON.stringify(users)); 113 | ``` 114 | 115 | ##### 💡 Get Details(with Following Threads) for a specific Thread (from v1.6.2) 116 | 117 | - `getThreads` but with auth (this will return more data) 118 | 119 | ```ts 120 | let data = await threadsAPI.getThreadsLoggedIn(postID); 121 | console.log(JSON.stringify(data.containing_thread)); 122 | console.log(JSON.stringify(data.reply_threads)); 123 | console.log(JSON.stringify(data.subling_threads)); 124 | 125 | if (data.downwards_thread_will_continue) { 126 | const cursor = data.paging_tokens.downward; 127 | data = await threadsAPI.getThreadsLoggedIn(postID, cursor); 128 | } 129 | ``` 130 | 131 | ##### 🔔 Get Notifications (from v1.6.2) 132 | 133 | ```ts 134 | let data = await threadsAPI.getNotifications( 135 | ThreadsAPI.NotificationFilter.MENTIONS, // {MENTIONS, REPLIES, VERIFIED} 136 | ); 137 | 138 | if (!data.is_last_page) { 139 | const cursor = data.next_max_id; 140 | data = await threadsAPI.getNotifications(ThreadsAPI.NotificationFilter.MENTIONS, cursor); 141 | } 142 | ``` 143 | 144 | ##### 💎 Get Recommended Users (from v1.6.2) 145 | 146 | ```ts 147 | let data = await threadsAPI.getRecommendedUsers(); 148 | console.log(JSON.stringify(data.users)); // ThreadsUser[] 149 | 150 | if (data.has_more) { 151 | const cursor = data.paging_token; 152 | data = await threadsAPI.getRecommendedUsers(cursor); 153 | } 154 | ``` 155 | 156 | ##### 🔍 Search Users (from v1.6.2) 157 | 158 | ```ts 159 | const query = 'zuck'; 160 | const count = 40; // default value is set to 30 161 | const data = await threadsAPI.searchUsers(query, count); 162 | 163 | console.log(JSON.stringify(data.num_results)); 164 | console.log(JSON.stringify(data.users)); // ThreadsUser[] 165 | ``` 166 | 167 | ### 🚀 Usage (Write) 168 | 169 | > **Note**
170 | > From v1.4.0, you can **also** call `login` to update your `token` and `userID`(for current credentials). Or you can just use the methods below, and they'll take care of the authentication automatically (e.g. if it's the first time you're using those). 171 | 172 | #### New API (from v1.2.0) 173 | 174 | ##### ✨ Text Threads 175 | 176 | ```ts 177 | import { ThreadsAPI } from 'threads-api'; 178 | 179 | const main = async () => { 180 | const threadsAPI = new ThreadsAPI({ 181 | username: '_junhoyeo', // Your username 182 | password: 'PASSWORD', // Your password 183 | }); 184 | 185 | await threadsAPI.publish({ 186 | text: '🤖 Hello World', 187 | }); 188 | }; 189 | 190 | main(); 191 | ``` 192 | 193 |

194 | 195 | Writing Text Threads 196 | 197 |

198 | 199 | > **💡 TIP**: Use the [`url` field in `ThreadsAPIPublishOptions` to render Link Attachments(link previews).](https://github.com/junhoyeo/threads-api#-threads-with-link-attachment) 200 | 201 | ###### ✨ Reply Control (from v1.4.6) 202 | 203 | ```ts 204 | await threadsAPI.publish({ 205 | text: '🤖 Threads with Reply Control', 206 | replyControl: 'accounts_you_follow', // 'everyone' | 'accounts_you_follow' | 'mentioned_only' 207 | }); 208 | ``` 209 | 210 | ##### ✨ Threads with Link Attachment 211 | 212 | ```ts 213 | await threadsAPI.publish({ 214 | text: '🤖 Threads with Link Attachment', 215 | attachment: { 216 | url: 'https://github.com/junhoyeo/threads-api', 217 | }, 218 | }); 219 | ``` 220 | 221 | ##### ✨ Threads with Image 222 | 223 | ```ts 224 | await threadsAPI.publish({ 225 | text: '🤖 Threads with Image', 226 | attachment: { 227 | image: 'https://github.com/junhoyeo/threads-api/raw/main/.github/cover.jpg', 228 | }, 229 | }); 230 | ``` 231 | 232 | `ThreadsAPI.Image` in `attachment.image` can also be type of `ThreadsAPI.ExternalImage` or `ThreadsAPI.RawImage`. 233 | 234 | ##### ✨ Threads with Sidecar (Multiple Images) 235 | 236 | > **Info**
237 | > The term _"sidecar"_ is what Threads uses to represent a group of images and/or videos that share the same post. 238 | 239 | ```ts 240 | await threadsAPI.publish({ 241 | text: '🤖 Threads with Sidecar', 242 | attachment: { 243 | sidecar: [ 244 | 'https://github.com/junhoyeo/threads-api/raw/main/.github/cover.jpg', 245 | 'https://github.com/junhoyeo/threads-api/raw/main/.github/cover.jpg', 246 | { path: './zuck.jpg' }, // ThreadsAPI.ExternalImage 247 | { type: '.jpg', data: Buffer.from(…) }, // ThreadsAPI.RawImage 248 | ], 249 | }, 250 | }); 251 | ``` 252 | 253 | ##### ✨ Reply to Other Threads 254 | 255 | ```ts 256 | const parentURL = 'https://www.threads.net/t/CugF-EjhQ3r'; 257 | const parentPostID = threadsAPI.getPostIDfromURL(parentURL); // or use `getPostIDfromThreadID` 258 | 259 | await threadsAPI.publish({ 260 | text: '🤖 Beep', 261 | link: 'https://github.com/junhoyeo/threads-api', 262 | parentPostID: parentPostID, 263 | }); 264 | ``` 265 | 266 |

267 | 268 | Writing Text Threads 269 | 270 |

271 | 272 | ##### ✨ Quote a Thread (from v1.4.2) 273 | 274 | ```ts 275 | const threadURL = 'https://www.threads.net/t/CuqbBI8h19H'; 276 | const postIDToQuote = threadsAPI.getPostIDfromURL(threadURL); // or use `getPostIDfromThreadID` 277 | 278 | await threadsAPI.publish({ 279 | text: '🤖 Quote a Thread', 280 | quotedPostID: postIDToQuote, 281 | }); 282 | ``` 283 | 284 | ##### ✨ Like/Unlike a Thread (from v1.3.0) 285 | 286 | ```ts 287 | const threadURL = 'https://www.threads.net/t/CugK35fh6u2'; 288 | const postIDToLike = threadsAPI.getPostIDfromURL(threadURL); // or use `getPostIDfromThreadID` 289 | 290 | // 💡 Uses current credentials 291 | await threadsAPI.like(postIDToLike); 292 | await threadsAPI.unlike(postIDToLike); 293 | ``` 294 | 295 | ##### ✨ Follow/Unfollow a User (from v1.3.0) 296 | 297 | ```ts 298 | const userIDToFollow = await threadsAPI.getUserIDfromUsername('junhoyeo'); 299 | 300 | // 💡 Uses current credentials 301 | await threadsAPI.follow(userIDToFollow); 302 | await threadsAPI.unfollow(userIDToFollow); 303 | ``` 304 | 305 | ##### ✨ Repost/Unrepost a Thread (from v1.4.2) 306 | 307 | ```ts 308 | const threadURL = 'https://www.threads.net/t/CugK35fh6u2'; 309 | const postIDToRepost = threadsAPI.getPostIDfromURL(threadURL); // or use `getPostIDfromThreadID` 310 | 311 | // 💡 Uses current credentials 312 | await threadsAPI.repost(postIDToRepost); 313 | await threadsAPI.unrepost(postIDToRepost); 314 | ``` 315 | 316 | ##### ✨ Delete a Post (from v1.3.1) 317 | 318 | ```ts 319 | const postID = await threadsAPI.publish({ 320 | text: '🤖 This message will self-destruct in 5 seconds.', 321 | }); 322 | 323 | await new Promise((resolve) => setTimeout(resolve, 5_000)); 324 | await threadsAPI.delete(postID); 325 | ``` 326 | 327 | ##### 🔇 Mute/Unmute a User/Post (from v1.6.2) 328 | 329 | ```ts 330 | const userID = await threadsAPI.getUserIDfromUsername('zuck'); 331 | const threadURL = 'https://www.threads.net/t/CugK35fh6u2'; 332 | const postID = threadsAPI.getPostIDfromURL(threadURL); // or use `getPostIDfromThreadID` 333 | 334 | // 💡 Uses current credentials 335 | 336 | // Mute User 337 | await threadsAPI.mute({ userID }); 338 | await threadsAPI.unfollow({ userID }); 339 | 340 | // Mute a Post of User 341 | await threadsAPI.mute({ userID, postID }); 342 | await threadsAPI.unfollow({ userID, postID }); 343 | ``` 344 | 345 | ##### 🔇 Block/Unblock a User (from v1.6.2) 346 | 347 | ```ts 348 | const userID = await threadsAPI.getUserIDfromUsername('zuck'); 349 | 350 | // 💡 Uses current credentials 351 | await threadsAPI.block({ userID }); 352 | await threadsAPI.unblock({ userID }); 353 | ``` 354 | 355 | ##### 🔔 Set Notifications Seen (from v1.6.2) 356 | 357 | ```ts 358 | // 💡 Uses current credentials 359 | await threadsAPI.setNotificationsSeen(); 360 | ``` 361 | 362 |
363 | 364 |

🏚️ Old API (Deprecated from v1.5.0, Still works for backwards compatibility)

365 |
image and url options in publish
366 |
367 | 368 | ##### ✨ Threads with Image 369 | 370 | ```ts 371 | await threadsAPI.publish({ 372 | text: '🤖 Threads with Image', 373 | image: 'https://github.com/junhoyeo/threads-api/raw/main/.github/cover.jpg', 374 | }); 375 | ``` 376 | 377 | ##### ✨ Threads with Link Attachment 378 | 379 | ```ts 380 | await threadsAPI.publish({ 381 | text: '🤖 Threads with Link Attachment', 382 | url: 'https://github.com/junhoyeo/threads-api', 383 | }); 384 | ``` 385 | 386 |
387 | 388 |
389 | 390 |

🏚️ Old API (Deprecated from v1.2.0, Still works for backwards compatibility)

391 |
Single string argument in publish
392 |
393 | 394 | ```ts 395 | import { ThreadsAPI } from 'threads-api'; 396 | 397 | const main = async () => { 398 | const threadsAPI = new ThreadsAPI({ 399 | username: 'jamel.hammoud', // Your username 400 | password: 'PASSWORD', // Your password 401 | }); 402 | 403 | await threadsAPI.publish('🤖 Hello World'); 404 | }; 405 | 406 | main(); 407 | ``` 408 | 409 | You can also provide custom `deviceID` (Default is `android-${(Math.random() * 1e24).toString(36)}`). 410 | 411 | ```ts 412 | const deviceID = `android-${(Math.random() * 1e24).toString(36)}`; 413 | 414 | const threadsAPI = new ThreadsAPI({ 415 | username: 'jamel.hammoud', 416 | password: 'PASSWORD', 417 | deviceID, 418 | }); 419 | ``` 420 | 421 |
422 | 423 | ## [](https://github.com/junhoyeo) Installation 424 | 425 | ```bash 426 | yarn add threads-api 427 | # or with npm 428 | npm install threads-api 429 | # or with pnpm 430 | pnpm install threads-api 431 | ``` 432 | 433 | ```typescript 434 | // or in Deno 🦖 435 | import ThreadsAPI from 'npm:threads-api'; 436 | 437 | const threadsAPI = new ThreadsAPI.ThreadsAPI({}); 438 | ``` 439 | 440 | ## [](https://github.com/junhoyeo) Roadmap 441 | 442 | - [x] ✅ Read public data 443 | - [x] ✅ Fetch UserID(`314216`) via username(`zuck`) 444 | - [x] ✅ Read timeline feed 445 | - [x] ✅ Read User Profile Info 446 | - [x] ✅ Read list of User Threads 447 | - [x] ✅ With Pagination (If auth provided) 448 | - [x] ✅ Read list of User Replies 449 | - [x] ✅ With Pagination (If auth provided) 450 | - [x] ✅ Fetch PostID(`3140957200974444958`) via ThreadID(`CuW6-7KyXme`) or PostURL(`https://www.threads.net/t/CuW6-7KyXme`) 451 | - [x] ✅ Read Threads via PostID 452 | - [x] ✅ Read Likers in Thread via PostID 453 | - [x] ✅ Read User Followers 454 | - [x] ✅ Read User Followings 455 | - [x] ✅ Write data (i.e. write automated Threads) 456 | - [x] ✅ Create new Thread with text 457 | - [x] ✅ Make link previews to get shown 458 | - [x] ✅ Create new Thread with a single image 459 | - [x] ✅ Create new Thread with multiple images 460 | - [x] ✅ Reply to existing Thread 461 | - [x] ✅ Quote Thread 462 | - [x] ✅ Delete Thread 463 | - [x] ✅ Friendships 464 | - [x] ✅ Follow User 465 | - [x] ✅ Unfollow User 466 | - [x] ✅ Interactions 467 | - [x] ✅ Like Thread 468 | - [x] ✅ Unlike Thread 469 | - [x] 🏴‍☠️ Restructure the project as a monorepo 470 | - [x] 🏴‍☠ Add Demo App with Next.js 471 | - [x] Use components in 🏴‍☠️ [junhoyeo/react-threads](https://github.com/junhoyeo/react-threads) 472 | - [ ] Make it better 473 | - [ ] Package with [:electron: Electron](https://github.com/electron/electron) 474 | - [x] 🏴‍☠️ Cool CLI App to run Threads in the Terminal 475 | 476 | ## [](https://github.com/junhoyeo) Projects made with `threads-api` 477 | 478 | > Add yours by just opening an [pull request](https://github.com/junhoyeo/threads-api/pulls)! 479 | 480 | ### [🏴‍☠️ `react-threads`: Embed Static Threads in your React/Next.js application.](https://github.com/junhoyeo/react-threads) 481 | 482 | [![NPM](https://img.shields.io/npm/v/react-threads.svg?style=flat-square&labelColor=black)](https://www.npmjs.com/package/react-threads) [![MIT License](https://img.shields.io/badge/license-MIT-blue?style=flat-square&labelColor=black)](https://github.com/junhoyeo/react-threads/blob/main/license) [![Prettier Code Formatting](https://img.shields.io/badge/code_style-prettier-brightgreen.svg?style=flat-square&labelColor=black)](https://prettier.io) [![](https://img.shields.io/github/stars/junhoyeo%2Freact-threads?style=social)](https://github.com/junhoyeo/react-threads) 483 | 484 | > Embed Static Threads in your React/Next.js application. UI components for Meta's Threads. _Powered by **junhoyeo/threads-api**._ 485 | 486 | [![cover](https://github.com/junhoyeo/react-threads/raw/main/.github/cover.jpg)](https://react-threads.vercel.app) 487 | 488 | #### Demo 489 | 490 | > **Warning**
491 | > Vercel Deployment is currently sometimes unstable. 🏴‍☠️ 492 | 493 | [![cover](https://github.com/junhoyeo/react-threads/raw/main/.github/cover-netflix.png)](https://react-threads.vercel.app/CuUoEcbRFma) 494 | 495 |
496 | 497 |

🏴‍☠️ threads-api CLI (WIP)

498 | 499 | To use the `threads-api` command line interface, run the following command: 500 | 501 |
502 | 503 | ```sh 504 | $ npx threads-api --help 505 | Usage: threads-api [command] [options] 506 | 507 | Options: 508 | -v, --version output the current version 509 | -h, --help display help for command 510 | 511 | Commands: 512 | help display help for command 513 | getUserIDfromUsername|userid|uid|id det user ID from username 514 | getUserProfile|userprofile|uprof|up [stringify] get user profile 515 | getUserProfileThreads|uthreads|ut [stringify] get user profile threads 516 | getUserProfileReplies|userreplies|ureplies|ur [stringify] get user profile replies 517 | getPostIDfromURL|postid|pid|p get post ID from URL 518 | getThreads|threads|t [stringify] get threads 519 | getThreadLikers|threadlikers|likers|l [stringify] get thread likers 520 | ``` 521 | 522 |
523 | 524 | ### [👤 `threads-card`: Share your Threads profile easily](https://github.com/yssf-io/threads-card) 525 | 526 | ### [👤 `Strings`: Web-Frontend for Threads](https://github.com/Nainish-Rai/strings-web) 527 | 528 | [![Screenshot (84)](https://github.com/Nainish-Rai/threads-api/assets/109546113/e8c4b990-6a95-470d-bbff-55a04f850b7b)](https://strings.vercel.app) 529 | 530 | ### [👤 `threads-projects`: Unleashing the power of Meta's Threads.net platform with insightful bots and efficient workflows](https://github.com/AayushGithub/threads-projects) 531 | 532 |

533 | 534 |

535 | 536 | ### [🧵 `thread-count`: Custom status badges for Meta's Threads.net follower counts](https://github.com/AayushGithub/thread-count) 537 | 538 |
539 | 540 | | parameter | demo | 541 | | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 542 | | `Default (_junhoyeo's account)` | _junhoyeo Badge | 543 | | `Custom Text and Colors` | Alternative Count Badge | 544 | | `Scale Badge Size` | ![https://www.threads.net/@zuck](https://thread-count.vercel.app/thread-count/zuck?scale=1.5) | 545 | 546 |
547 | 548 | ### [🤖 `thread-year-prog-bot`: Bot weaving the fabric of time](https://github.com/SethuSenthil/thread-year-prog-bot) 549 | 550 | 551 | 552 | ## License 553 | 554 |

555 | 556 | 557 | 558 |

559 | 560 |

561 | MIT © Junho Yeo 562 |

563 | 564 | If you find this project intriguing, **please consider starring it(⭐)** or following me on [GitHub](https://github.com/junhoyeo) (I wouldn't say [Threads](https://www.threads.net/@_junhoyeo)). I code 24/7 and ship mind-breaking things on a regular basis, so your support definitely won't be in vain. 565 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | threads-api/README.md -------------------------------------------------------------------------------- /fetch-dynamic-data/index.deno.ts: -------------------------------------------------------------------------------- 1 | // * Run this file to fetch dynamic data (such as app versions) and save it to the api 2 | // * This allows user agent formulating to be more accurate and less likely to be detected 3 | //? RUN COMMAND: deno run --allow-read --allow-write --allow-env --allow-net index.deno.ts 4 | import * as path from 'https://deno.land/std@0.177.0/path/mod.ts'; 5 | 6 | let androidData = await fetch('https://m.apkpure.com/threads-an-instagram-app/com.instagram.barcelona', { 7 | headers: { 8 | accept: 9 | 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8', 10 | 'accept-language': 'en-US,en;q=0.6', 11 | 'cache-control': 'max-age=0', 12 | 'sec-ch-ua': '"Not.A/Brand";v="8", "Chromium";v="114", "Brave";v="114"', 13 | 'sec-ch-ua-mobile': '?0', 14 | 'sec-ch-ua-platform': '"macOS"', 15 | 'sec-fetch-dest': 'document', 16 | 'sec-fetch-mode': 'navigate', 17 | 'sec-fetch-site': 'cross-site', 18 | 'sec-fetch-user': '?1', 19 | 'sec-gpc': '1', 20 | 'upgrade-insecure-requests': '1', 21 | Referer: 'https://www.google.com/', 22 | 'Referrer-Policy': 'origin', 23 | 'User-Agent': 24 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36', 25 | }, 26 | body: null, 27 | method: 'GET', 28 | }); 29 | let androidDataText = await androidData.text(); 30 | const latestAppVersion = androidDataText 31 | .split(`

`)[1] 32 | .split('

')[0] 33 | .split('')[1] 34 | .split('')[0]; 35 | console.log('Latest ANDROID App Version:', latestAppVersion); 36 | 37 | const dynamicData = `export const LATEST_ANDROID_APP_VERSION = '${latestAppVersion}';`; 38 | 39 | const __dirname = new URL('.', import.meta.url).pathname; 40 | 41 | const dynamicDataFile = path.join(__dirname, '../', 'threads-api', 'src', 'dynamic-data.ts'); 42 | 43 | await Deno.writeTextFile(dynamicDataFile, dynamicData); 44 | 45 | console.log('📝 Saved and synced dynamic data to api'); 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "threads-api-project", 3 | "private": true, 4 | "workspaces": [ 5 | "threads-api", 6 | "threads-web-ui" 7 | ], 8 | "repository": "https://github.com/junhoyeo/threads-api", 9 | "author": "Junho Yeo ", 10 | "scripts": { 11 | "format": "prettier --write ." 12 | }, 13 | "devDependencies": { 14 | "@trivago/prettier-plugin-sort-imports": "^4.1.1", 15 | "prettier": "^2.8.0" 16 | }, 17 | "packageManager": "yarn@3.6.1" 18 | } 19 | -------------------------------------------------------------------------------- /threads-api/.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "minify": true, 3 | "jsc": { 4 | "loose": true, 5 | "parser": { 6 | "syntax": "typescript", 7 | "tsx": false 8 | }, 9 | "minify": { 10 | "compress": { 11 | "unused": true 12 | }, 13 | "mangle": true 14 | } 15 | }, 16 | "module": { 17 | "type": "commonjs", 18 | "strict": true, 19 | "noInterop": true 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /threads-api/README.md: -------------------------------------------------------------------------------- 1 | # [](https://github.com/junhoyeo) Threads API 2 | 3 | [![NPM](https://img.shields.io/npm/v/threads-api.svg?style=flat-square&labelColor=black)](https://www.npmjs.com/package/threads-api) [![MIT License](https://img.shields.io/badge/license-MIT-blue?style=flat-square&labelColor=black)](https://github.com/junhoyeo/threads-api/blob/main/LICENSE) [![Prettier Code Formatting](https://img.shields.io/badge/code_style-prettier-brightgreen.svg?style=flat-square&labelColor=black)](https://prettier.io) 4 | 5 | Unofficial, Reverse-Engineered Node.js/TypeScript client for Meta's [Threads](https://threads.net). 6 | 7 | > **Warning**
8 | > **As of September 8, 2023, the development of the "threads-api" project have been halted and discontinued due to communication received from Meta Platforms, Inc. (“Meta,” previously known as Facebook, Inc.). This repository, along with related projects [threads-py](https://github.com/junhoyeo/threads-py) and [react-threads](https://github.com/junhoyeo/react-threads), has been archived and will no longer receive updates or maintenance.** The previous documentation related to this project has been moved to [PRESERVED.md](https://github.com/junhoyeo/threads-api/blob/main/PRESERVED.md) as requested. 9 | > 10 | > The "threads-api" was developed for educational and research purposes only. Based on the notification from Meta, it's clear that using or distributing the code might violate the terms of service of Meta Platforms, Inc. and its associated services, including but not limited to Instagram and Threads. Any actions or activities related to the material contained within this repository are solely the user's responsibility. The author and contributors of this repository do not support or condone any unethical or illegal activities. 11 | > 12 | > C_D_Scraping 13 | -------------------------------------------------------------------------------- /threads-api/__test__/.env.example: -------------------------------------------------------------------------------- 1 | USERNAME= 2 | PASSWORD= 3 | -------------------------------------------------------------------------------- /threads-api/__test__/getPostIDfromThreadID.test.ts: -------------------------------------------------------------------------------- 1 | import { ThreadsAPI } from '../src/threads-api'; 2 | 3 | describe('getPostIDfromThreadID', () => { 4 | const threadsAPI = new ThreadsAPI({ verbose: true }); 5 | 6 | describe('fetching postID with ThreadID', () => { 7 | let postID: string | undefined; 8 | 9 | beforeAll(async () => { 10 | // given 11 | const threadID = 'CuX_UYABrr7'; 12 | 13 | // when 14 | postID = threadsAPI.getPostIDfromThreadID(threadID); 15 | }); 16 | 17 | it('should return postID', async () => { 18 | // then 19 | expect(postID).toBe('3141257742204189435'); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /threads-api/__test__/getPostIDfromURL.test.ts: -------------------------------------------------------------------------------- 1 | import { ThreadsAPI } from '../src/threads-api'; 2 | 3 | describe('getPostIDfromURL', () => { 4 | const threadsAPI = new ThreadsAPI({ verbose: true }); 5 | 6 | describe('fetching postID with postURL', () => { 7 | let postID: string | undefined; 8 | 9 | beforeAll(async () => { 10 | // given 11 | const postURL = 'https://www.threads.net/t/CuX_UYABrr7/?igshid=MzRlODBiNWFlZA=='; 12 | 13 | // when 14 | postID = threadsAPI.getPostIDfromURL(postURL); 15 | }); 16 | 17 | it('should return postID', async () => { 18 | // then 19 | expect(postID).toBe('3141257742204189435'); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /threads-api/__test__/getThreadLikers.test.ts: -------------------------------------------------------------------------------- 1 | import { ThreadsAPI } from '../src/threads-api'; 2 | 3 | test('getThreadLikers', async () => { 4 | // given 5 | const threadsAPI = new ThreadsAPI(); 6 | const postID = '3141675920411513399'; 7 | 8 | // when 9 | const likers = await threadsAPI.getThreadLikers(postID); 10 | 11 | // then 12 | expect(Array.isArray(likers.users)).toBe(true); 13 | expect(likers.users[0]).toHaveProperty('pk'); 14 | expect(likers.users[0]).toHaveProperty('full_name'); 15 | }); 16 | -------------------------------------------------------------------------------- /threads-api/__test__/getThreads.test.ts: -------------------------------------------------------------------------------- 1 | import { ThreadsAPI } from '../src/threads-api'; 2 | 3 | test('getThreads', async () => { 4 | // given 5 | const threadsAPI = new ThreadsAPI(); 6 | const postID = '3140957200974444958'; // https://www.threads.net/t/CuW6-7KyXme 7 | 8 | // when 9 | const thread = await threadsAPI.getThreads(postID); 10 | 11 | // then 12 | expect(thread).toHaveProperty('reply_threads'); 13 | expect(thread).toHaveProperty('containing_thread'); 14 | expect(thread.containing_thread.id).toBe(postID); 15 | expect(Array.isArray(thread.reply_threads)).toBe(true); 16 | 17 | // hope we don't remove those threads! 18 | const containingThreadCaptions = thread.containing_thread.thread_items.map((v) => v.post.caption?.text); 19 | expect(containingThreadCaptions).toEqual( 20 | expect.arrayContaining([ 21 | 'This is fast. Could be made into a Mastodon API bridge like Skybridge (for Bluesky)', 22 | 'For context, this is Skybridge https://skybridge.fly.dev/', 23 | ]), 24 | ); 25 | 26 | const replyThreadCaptions = thread.reply_threads 27 | ?.map((v) => v.thread_items.map((v) => v.post.caption?.text)) 28 | .flat(); 29 | expect(replyThreadCaptions).toEqual(expect.arrayContaining(['🤍💙🤍💙💙'])); 30 | }); 31 | -------------------------------------------------------------------------------- /threads-api/__test__/getTimeline.test.ts: -------------------------------------------------------------------------------- 1 | import { ThreadsAPI } from '../src/threads-api'; 2 | import { TIMEOUT, credentials } from './utils/constants'; 3 | import { describeIf } from './utils/describeIf'; 4 | 5 | describeIf(!!credentials)('getTimeline', () => { 6 | const threadsAPI = new ThreadsAPI({ 7 | verbose: true, 8 | ...credentials, 9 | }); 10 | 11 | it( 12 | 'Fetch timeline feed', 13 | async () => { 14 | // when 15 | let { items: threads, next_max_id } = await threadsAPI.getTimeline(); 16 | const firstPageNextMaxID = next_max_id; 17 | console.log('[FIRST PAGE]', next_max_id); 18 | 19 | // then 20 | expect(Array.isArray(threads)).toBe(true); 21 | expect(threads[0]).toHaveProperty('thread_items'); 22 | expect(threads[0].thread_items[0]).toHaveProperty('post'); 23 | expect(next_max_id).toBeTruthy(); 24 | 25 | // next page 26 | const res = await threadsAPI.getTimeline(next_max_id); 27 | threads = res.items; 28 | next_max_id = res.next_max_id; 29 | console.log('[SECOND PAGE]', next_max_id); 30 | 31 | expect(Array.isArray(threads)).toBe(true); 32 | expect(threads[0]).toHaveProperty('thread_items'); 33 | expect(threads[0].thread_items[0]).toHaveProperty('post'); 34 | 35 | expect(next_max_id).toBeTruthy(); 36 | expect(next_max_id).not.toEqual(firstPageNextMaxID); 37 | }, 38 | TIMEOUT, 39 | ); 40 | }); 41 | -------------------------------------------------------------------------------- /threads-api/__test__/getToken.test.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import createHttpsProxyAgent from 'https-proxy-agent'; 3 | 4 | import { ThreadsAPI } from '../src/threads-api'; 5 | import { DEVICE_ID, TIMEOUT, rawCredentials as credentials } from './utils/constants'; 6 | import { describeIf } from './utils/describeIf'; 7 | 8 | describeIf(!!credentials)('getToken', () => { 9 | const threadsAPI = new ThreadsAPI({ 10 | verbose: true, 11 | deviceID: DEVICE_ID, 12 | ...credentials, 13 | }); 14 | 15 | describe('login from Instagram', () => { 16 | let token: string | undefined; 17 | 18 | beforeAll(async () => { 19 | // when 20 | token = await threadsAPI.getToken(); 21 | }, TIMEOUT); 22 | 23 | it( 24 | 'should return token', 25 | async () => { 26 | // then 27 | expect(typeof token).toBe('string'); 28 | }, 29 | TIMEOUT, 30 | ); 31 | }); 32 | 33 | it('should use the correct proxy URL if given', async () => { 34 | // given 35 | const mockAxios = jest.spyOn(axios, 'post'); 36 | const httpsProxyAgent = createHttpsProxyAgent({ 37 | host: 'mocked-proxy', 38 | }); 39 | const threadsAPI = new ThreadsAPI({ 40 | verbose: true, 41 | deviceID: DEVICE_ID, 42 | httpsAgent: httpsProxyAgent, 43 | ...credentials, 44 | }); 45 | 46 | // when 47 | await threadsAPI.getToken().catch(() => {}); 48 | 49 | // then 50 | const call = mockAxios.mock.calls[0]; 51 | expect(call[2]?.httpsAgent?.proxy?.host).toBe('mocked-proxy'); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /threads-api/__test__/getUserFollows.test.ts: -------------------------------------------------------------------------------- 1 | import { ThreadsAPI } from '../src/threads-api'; 2 | import { TIMEOUT, credentials } from './utils/constants'; 3 | import { describeIf } from './utils/describeIf'; 4 | 5 | describeIf(!!credentials)('followers/followings', () => { 6 | const threadsAPI = new ThreadsAPI({ 7 | verbose: true, 8 | ...credentials, 9 | }); 10 | 11 | it( 12 | 'Fetch user followers with pagination.', 13 | async () => { 14 | // given 15 | const userID = '5438123050'; 16 | 17 | // when 18 | let { users, next_max_id: cursor } = await threadsAPI.getUserFollowers(userID); 19 | const firstCursor = cursor; 20 | 21 | // then 22 | expect(users.length).toBeGreaterThan(0); 23 | expect(cursor).toBeTruthy(); 24 | 25 | // next page 26 | ({ users, next_max_id: cursor } = await threadsAPI.getUserFollowers(userID, { maxID: cursor })); 27 | expect(users.length).toBeGreaterThan(0); 28 | expect(cursor).toBeTruthy(); 29 | expect(cursor).not.toEqual(firstCursor); 30 | }, 31 | TIMEOUT, 32 | ); 33 | 34 | it( 35 | 'Fetch user followings with pagination.', 36 | async () => { 37 | // given 38 | const userID = '5438123050'; 39 | 40 | // when 41 | let { users, next_max_id: cursor } = await threadsAPI.getUserFollowings(userID); 42 | const firstCursor = cursor; 43 | 44 | // then 45 | expect(users.length).toBeGreaterThan(0); 46 | expect(cursor).toBeTruthy(); 47 | 48 | // next page 49 | ({ users, next_max_id: cursor } = await threadsAPI.getUserFollowings(userID, { maxID: cursor })); 50 | expect(users.length).toBeGreaterThan(0); 51 | expect(cursor).toBeTruthy(); 52 | expect(cursor).not.toEqual(firstCursor); 53 | }, 54 | TIMEOUT, 55 | ); 56 | }); 57 | -------------------------------------------------------------------------------- /threads-api/__test__/getUserIDfromUsername.test.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | import { ThreadsAPI } from '../src/threads-api'; 4 | 5 | describe('getUserIDfromUsername', () => { 6 | const threadsAPI = new ThreadsAPI({ verbose: true }); 7 | 8 | describe('fetching userID for username', () => { 9 | let userID: string | undefined; 10 | let previousLSDToken: string; 11 | 12 | beforeAll(async () => { 13 | // given 14 | const username = '_junhoyeo'; 15 | threadsAPI.fbLSDToken = 'mocked-default-lsd-token'; 16 | previousLSDToken = threadsAPI.fbLSDToken; 17 | 18 | // when 19 | userID = await threadsAPI.getUserIDfromUsername(username); 20 | }); 21 | 22 | it('should return userID', async () => { 23 | // then 24 | expect(userID).toBe('5438123050'); 25 | }); 26 | 27 | describe('after execution', () => { 28 | it('should update LSD token', async () => { 29 | // then 30 | expect(threadsAPI.fbLSDToken).not.toBe(previousLSDToken); 31 | }); 32 | 33 | it('should use updated LSD for future requests', async () => { 34 | // given — mock axios 35 | const mockAxios = jest.spyOn(axios, 'post'); 36 | const latestLSDToken = threadsAPI.fbLSDToken; 37 | 38 | // when 39 | await threadsAPI.getUserProfile('5438123050'); 40 | 41 | // then 42 | const call = mockAxios.mock.calls[0]; 43 | const lsd = (call[1] as URLSearchParams | undefined)?.get('lsd'); 44 | if (!lsd) { 45 | throw new Error('lsd is undefined'); 46 | } 47 | expect(lsd).toBe(latestLSDToken); 48 | expect(lsd).not.toBe(previousLSDToken); 49 | }); 50 | }); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /threads-api/__test__/getUserProfile.test.ts: -------------------------------------------------------------------------------- 1 | import { ThreadsAPI } from '../src/threads-api'; 2 | 3 | test('getUserProfile', async () => { 4 | // given 5 | const threadsAPI = new ThreadsAPI(); 6 | const username = '_junhoyeo'; 7 | const userID = '5438123050'; 8 | 9 | // when 10 | const user = await threadsAPI.getUserProfile(userID); 11 | 12 | // then 13 | expect(user.username).toBe(username); 14 | }); 15 | -------------------------------------------------------------------------------- /threads-api/__test__/getUserProfileReplies.test.ts: -------------------------------------------------------------------------------- 1 | import { ThreadsAPI } from '../src/threads-api'; 2 | import { TIMEOUT, credentials } from './utils/constants'; 3 | import { describeIf } from './utils/describeIf'; 4 | 5 | test('getUserProfileReplies (without auth)', async () => { 6 | // given 7 | const threadsAPI = new ThreadsAPI(); 8 | const userID = '5438123050'; 9 | 10 | // when 11 | const posts = await threadsAPI.getUserProfileReplies(userID); 12 | 13 | // then 14 | expect(Array.isArray(posts)).toBe(true); 15 | expect(posts[0]).toHaveProperty('thread_items'); 16 | expect(posts[0].thread_items.length).toBeGreaterThanOrEqual(2); 17 | }); 18 | 19 | describeIf(!!credentials)('getUserProfileRepliesLoggedIn (with auth)', () => { 20 | const threadsAPI = new ThreadsAPI({ 21 | verbose: true, 22 | ...credentials, 23 | }); 24 | 25 | it( 26 | 'Fetch user replies with pagination.', 27 | async () => { 28 | // given 29 | const userID = '5438123050'; 30 | 31 | // when 32 | let { threads, next_max_id } = await threadsAPI.getUserProfileRepliesLoggedIn(userID); 33 | const firstPageNextMaxID = next_max_id; 34 | console.log('[FIRST PAGE]', next_max_id); 35 | 36 | // then 37 | expect(Array.isArray(threads)).toBe(true); 38 | expect(threads[0]).toHaveProperty('thread_items'); 39 | expect(threads[0].thread_items[0]).toHaveProperty('post'); 40 | expect(next_max_id).toBeTruthy(); 41 | 42 | // next page 43 | const res = await threadsAPI.getUserProfileRepliesLoggedIn(userID); 44 | threads = res.threads; 45 | next_max_id = res.next_max_id; 46 | console.log('[SECOND PAGE]', next_max_id); 47 | 48 | expect(Array.isArray(threads)).toBe(true); 49 | expect(threads[0]).toHaveProperty('thread_items'); 50 | expect(threads[0].thread_items[0]).toHaveProperty('post'); 51 | 52 | expect(next_max_id).toBeTruthy(); 53 | expect(next_max_id).not.toEqual(firstPageNextMaxID); 54 | }, 55 | TIMEOUT, 56 | ); 57 | }); 58 | -------------------------------------------------------------------------------- /threads-api/__test__/getUserProfileThreads.test.ts: -------------------------------------------------------------------------------- 1 | import { ThreadsAPI } from '../src/threads-api'; 2 | import { TIMEOUT, credentials } from './utils/constants'; 3 | import { describeIf } from './utils/describeIf'; 4 | 5 | test('getUserProfileThreads (without auth)', async () => { 6 | // given 7 | const threadsAPI = new ThreadsAPI(); 8 | const userID = '5438123050'; 9 | 10 | // when 11 | const posts = await threadsAPI.getUserProfileThreads(userID); 12 | 13 | // then 14 | expect(Array.isArray(posts)).toBe(true); 15 | expect(posts[0]).toHaveProperty('thread_items'); 16 | expect(posts[0].thread_items[0]).toHaveProperty('post'); 17 | }); 18 | 19 | describeIf(!!credentials)('getUserProfileThreadsLoggedIn (with auth)', () => { 20 | const threadsAPI = new ThreadsAPI({ 21 | verbose: true, 22 | ...credentials, 23 | }); 24 | 25 | it( 26 | 'Fetch user threads with pagination.', 27 | async () => { 28 | // given 29 | const userID = '5438123050'; 30 | 31 | // when 32 | let { threads, next_max_id } = await threadsAPI.getUserProfileThreadsLoggedIn(userID); 33 | const firstPageNextMaxID = next_max_id; 34 | console.log('[FIRST PAGE]', next_max_id); 35 | 36 | // then 37 | expect(Array.isArray(threads)).toBe(true); 38 | expect(threads[0]).toHaveProperty('thread_items'); 39 | expect(threads[0].thread_items[0]).toHaveProperty('post'); 40 | expect(next_max_id).toBeTruthy(); 41 | 42 | // next page 43 | const res = await threadsAPI.getUserProfileThreadsLoggedIn(userID); 44 | threads = res.threads; 45 | next_max_id = res.next_max_id; 46 | console.log('[SECOND PAGE]', next_max_id); 47 | 48 | expect(Array.isArray(threads)).toBe(true); 49 | expect(threads[0]).toHaveProperty('thread_items'); 50 | expect(threads[0].thread_items[0]).toHaveProperty('post'); 51 | 52 | expect(next_max_id).toBeTruthy(); 53 | expect(next_max_id).not.toEqual(firstPageNextMaxID); 54 | }, 55 | TIMEOUT, 56 | ); 57 | }); 58 | -------------------------------------------------------------------------------- /threads-api/__test__/interaction-follow-and-unfollow.test.ts: -------------------------------------------------------------------------------- 1 | import { ThreadsAPI } from '../src/threads-api'; 2 | import { TIMEOUT, credentials } from './utils/constants'; 3 | import { describeIf } from './utils/describeIf'; 4 | 5 | describeIf(!!credentials)('Follow/Unfollow', () => { 6 | const threadsAPI = new ThreadsAPI({ 7 | verbose: true, 8 | ...credentials, 9 | }); 10 | 11 | it( 12 | 'Follow/Unfollow a Thread with User ID and Post ID', 13 | async () => { 14 | let success = false; 15 | 16 | // given 17 | const userID = (await threadsAPI.getUserIDfromUsername('junhoyeo')) || ''; 18 | 19 | // follow 20 | await new Promise((resolve) => setTimeout(resolve, 1_000)); // delay for safety 21 | success = (await threadsAPI.follow(userID)).status === 'ok'; 22 | expect(success).toBe(true); 23 | success = false; 24 | 25 | // unfollow 26 | await new Promise((resolve) => setTimeout(resolve, 1_000)); // delay for safety 27 | success = (await threadsAPI.unfollow(userID)).status === 'ok'; 28 | expect(success).toBe(true); 29 | success = false; 30 | 31 | // follow me back thanks! lol 32 | await new Promise((resolve) => setTimeout(resolve, 1_000)); // delay for safety 33 | success = (await threadsAPI.follow(userID)).status === 'ok'; 34 | expect(success).toBe(true); 35 | success = false; 36 | }, 37 | TIMEOUT, 38 | ); 39 | }); 40 | -------------------------------------------------------------------------------- /threads-api/__test__/interaction-like-and-unlike.test.ts: -------------------------------------------------------------------------------- 1 | import { ThreadsAPI } from '../src/threads-api'; 2 | import { TIMEOUT, credentials } from './utils/constants'; 3 | import { describeIf } from './utils/describeIf'; 4 | 5 | describeIf(!!credentials)('Like/Unlike', () => { 6 | const threadsAPI = new ThreadsAPI({ 7 | verbose: true, 8 | ...credentials, 9 | }); 10 | 11 | it( 12 | 'Like/Unlike a Thread with User ID and Post ID', 13 | async () => { 14 | let success = false; 15 | 16 | // given 17 | const threadURL = 'https://www.threads.net/t/CugDXa1hMza'; 18 | const postID = threadsAPI.getPostIDfromURL(threadURL) || ''; 19 | 20 | // like 21 | await new Promise((resolve) => setTimeout(resolve, 1_000)); // delay for safety 22 | success = await threadsAPI.like(postID); 23 | expect(success).toBe(true); 24 | success = false; 25 | 26 | // unlike 27 | await new Promise((resolve) => setTimeout(resolve, 1_000)); // delay for safety 28 | success = await threadsAPI.unlike(postID); 29 | expect(success).toBe(true); 30 | success = false; 31 | }, 32 | TIMEOUT, 33 | ); 34 | }); 35 | -------------------------------------------------------------------------------- /threads-api/__test__/login.test.ts: -------------------------------------------------------------------------------- 1 | import { ThreadsAPI } from '../src/threads-api'; 2 | import { TIMEOUT, rawCredentials as credentials } from './utils/constants'; 3 | import { describeIf } from './utils/describeIf'; 4 | 5 | describeIf(!!credentials)('login', () => { 6 | const threadsAPI = new ThreadsAPI({ 7 | verbose: true, 8 | ...credentials, 9 | }); 10 | 11 | it( 12 | 'Login and publish text thread.', 13 | async () => { 14 | // given 15 | const text = "🤖 I'm back, Threads."; 16 | await threadsAPI.login(); 17 | 18 | // when 19 | await new Promise((resolve) => setTimeout(resolve, 1_000)); // delay for safety 20 | const success = !!(await threadsAPI.publish({ text })); 21 | 22 | // then 23 | expect(success).toBe(true); 24 | }, 25 | TIMEOUT, 26 | ); 27 | }); 28 | -------------------------------------------------------------------------------- /threads-api/__test__/publish.test.ts: -------------------------------------------------------------------------------- 1 | import { ThreadsAPI } from '../src/threads-api'; 2 | import { TIMEOUT, credentials } from './utils/constants'; 3 | import { describeIf } from './utils/describeIf'; 4 | 5 | describeIf(!!credentials)('publish', () => { 6 | const threadsAPI = new ThreadsAPI({ 7 | verbose: true, 8 | ...credentials, 9 | }); 10 | 11 | describe('Publish a text post to Threads.', () => { 12 | let success: boolean = false; 13 | 14 | beforeAll(async () => { 15 | // when 16 | await new Promise((resolve) => setTimeout(resolve, 1_000)); // delay for safety 17 | success = !!(await threadsAPI.publish('🤖 Hello World!')); 18 | }, TIMEOUT); 19 | 20 | it( 21 | 'should return success', 22 | async () => { 23 | // then 24 | expect(success).toBe(true); 25 | }, 26 | TIMEOUT, 27 | ); 28 | }); 29 | 30 | it( 31 | 'Publish a text post to Threads with an image.', 32 | async () => { 33 | // given 34 | const text = '🤖 Hello World!'; 35 | const imageURL = 'https://github.com/junhoyeo/threads-api/blob/main/.github/logo.jpg?raw=true'; 36 | 37 | // when 38 | await new Promise((resolve) => setTimeout(resolve, 1_000)); // delay for safety 39 | const success = !!(await threadsAPI.publish({ text, image: imageURL })); 40 | 41 | // then 42 | expect(success).toBe(true); 43 | }, 44 | TIMEOUT, 45 | ); 46 | 47 | it( 48 | 'Publish a text post to Threads with an URL attrachment.', 49 | async () => { 50 | // given 51 | const text = '🤖 Hello World!'; 52 | const imageURL = 'https://github.com/junhoyeo/threads-api'; 53 | 54 | // when 55 | await new Promise((resolve) => setTimeout(resolve, 1_000)); // delay for safety 56 | const success = !!(await threadsAPI.publish({ text, url: imageURL })); 57 | 58 | // then 59 | expect(success).toBe(true); 60 | }, 61 | TIMEOUT, 62 | ); 63 | }); 64 | -------------------------------------------------------------------------------- /threads-api/__test__/publishWithImage.test.ts: -------------------------------------------------------------------------------- 1 | import { ThreadsAPI } from '../src/threads-api'; 2 | 3 | describe('publishWithImage (deprecated)', () => { 4 | it('Route implementation to new publish method', async () => { 5 | // given 6 | const threadsAPI = new ThreadsAPI({ 7 | verbose: true, 8 | username: 'mocked-username', 9 | password: 'mocked-password', 10 | token: 'mocked-token', 11 | }); 12 | const imageURL = 'https://github.com/junhoyeo/threads-api/blob/main/.github/logo.jpg?raw=true'; 13 | 14 | const publishSpy = jest.spyOn(threadsAPI, 'publish'); 15 | publishSpy.mockImplementation(() => Promise.resolve('mocked-id')); 16 | 17 | // when 18 | await new Promise((resolve) => setTimeout(resolve, 1_000)); // delay for safety 19 | await threadsAPI.publishWithImage('🤖 Hello World!', imageURL); 20 | 21 | // then 22 | expect(publishSpy).toHaveBeenCalledWith({ 23 | text: '🤖 Hello World!', 24 | image: imageURL, 25 | }); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /threads-api/__test__/setup-tests.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | import path from 'path'; 3 | 4 | dotenv.config({ 5 | path: path.resolve(__dirname, './.env'), 6 | override: true, 7 | }); 8 | -------------------------------------------------------------------------------- /threads-api/__test__/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const TIMEOUT = 100_000; 2 | export const DEVICE_ID = `android-3otj6ebq86q00000`; 3 | 4 | export const rawCredentials = 5 | !process.env.USERNAME || !process.env.PASSWORD 6 | ? null 7 | : { username: process.env.USERNAME, password: process.env.PASSWORD }; 8 | 9 | export const credentials = 10 | !process.env.USERNAME || !process.env.TOKEN // 11 | ? rawCredentials 12 | : { username: process.env.USERNAME, token: process.env.TOKEN }; 13 | -------------------------------------------------------------------------------- /threads-api/__test__/utils/describeIf.ts: -------------------------------------------------------------------------------- 1 | export const describeIf = (condition: boolean) => (condition ? describe : describe.skip); 2 | -------------------------------------------------------------------------------- /threads-api/bin/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const { program } = require('../build/cli'); 3 | program.parse(process.argv); 4 | -------------------------------------------------------------------------------- /threads-api/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | testMatch: ['**/?(*.)+(spec|test).+(ts|tsx|js)'], 5 | transform: { 6 | '^.+\\.(ts|tsx)$': 'ts-jest', 7 | }, 8 | setupFiles: ['/__test__/setup-tests.ts'], 9 | }; 10 | -------------------------------------------------------------------------------- /threads-api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "threads-api", 3 | "version": "1.6.3", 4 | "description": "Unofficial, Reverse-Engineered Node.js/TypeScript client for Meta's [Threads](https://threads.net).", 5 | "author": "Junho Yeo ", 6 | "repository": "https://github.com/junhoyeo/threads-api", 7 | "license": "MIT", 8 | "type": "commonjs", 9 | "source": "./src/index.ts", 10 | "main": "./build/index.js", 11 | "types": "./build/index.d.ts", 12 | "exports": { 13 | ".": { 14 | "types": "./build/index.d.ts", 15 | "import": "./build/index.js", 16 | "default": "./build/index.js" 17 | } 18 | }, 19 | "bin": "./bin/cli.js", 20 | "engines": { 21 | "node": ">=14" 22 | }, 23 | "files": [ 24 | "build" 25 | ], 26 | "scripts": { 27 | "build": "rimraf ./lib && swc src --config-file .swcrc -d build && tsc --emitDeclarationOnly", 28 | "test": "jest" 29 | }, 30 | "devDependencies": { 31 | "@babel/core": "^7.22.8", 32 | "@babel/preset-env": "^7.22.7", 33 | "@swc/cli": "^0.1.62", 34 | "@swc/core": "^1.3.70", 35 | "@types/jest": "^29.5.2", 36 | "@types/node": "^20.4.0", 37 | "@types/uuid": "^9.0.2", 38 | "babel-jest": "^29.6.1", 39 | "commander": "^11.0.0", 40 | "dotenv": "^16.3.1", 41 | "jest": "^29.6.1", 42 | "rimraf": "^5.0.1", 43 | "ts-jest": "^29.1.1", 44 | "tslib": "^2.6.0", 45 | "typescript": "^5.1.6" 46 | }, 47 | "dependencies": { 48 | "axios": "^1.4.0", 49 | "bloks-tool": "^0.0.1", 50 | "mrmime": "^1.0.1", 51 | "uuid": "^9.0.0" 52 | }, 53 | "keywords": [ 54 | "threads", 55 | "instagram", 56 | "facebook", 57 | "meta", 58 | "api" 59 | ] 60 | } 61 | -------------------------------------------------------------------------------- /threads-api/src/bloks-types.ts: -------------------------------------------------------------------------------- 1 | export type LoginResponseBlok = [ 2 | 'bk.action.caa.HandleLoginResponse', 3 | [ 4 | 'bk.action.tree.Make', 5 | ['bk.action.i32.Const', number], 6 | ['bk.action.i32.Const', number], 7 | string, 8 | ['bk.action.i32.Const', number], 9 | 'Password', 10 | ['bk.action.i32.Const', number], 11 | 'Login', 12 | ], 13 | null, 14 | ['bk.action.core.GetArg', number], 15 | ]; 16 | 17 | export type ThreadsLoginFailResponseBlok = [ 18 | 'ig.action.cdsdialog.OpenDialog', 19 | [ 20 | 'bk.action.tree.Make', 21 | ['bk.action.i32.Const', number], 22 | ['bk.action.i32.Const', number], 23 | 'Incorrect Password', 24 | ['bk.action.i32.Const', number], 25 | string, 26 | ['bk.action.i32.Const', number], 27 | [ 28 | 'bk.action.tree.Make', 29 | ['bk.action.i32.Const', number], 30 | ['bk.action.i32.Const', number], 31 | 'OK', 32 | ['bk.action.i32.Const', number], 33 | [ 34 | 'bk.action.core.FuncConst', 35 | [ 36 | 'bk.action.logging.LogEvent', 37 | 'caa_login_client_events_ig', 38 | '', 39 | [ 40 | 'bk.action.map.Make', 41 | ['bk.action.array.Make', 'core', 'login_params'], 42 | [ 43 | 'bk.action.array.Make', 44 | [ 45 | 'bk.action.map.Make', 46 | [ 47 | 'bk.action.array.Make', 48 | 'event', 49 | 'event_category', 50 | 'event_flow', 51 | 'event_request_id', 52 | 'event_step', 53 | 'is_dark_mode', 54 | 'exception_code', 55 | 'exception_message', 56 | 'exception_type', 57 | 'extra_client_data', 58 | 'logged_out_identifier', 59 | 'logged_in_identifier', 60 | 'waterfall_id', 61 | ], 62 | [ 63 | 'bk.action.array.Make', 64 | 'login_error_dialog_ok_clicked', 65 | 'login_home_page_interaction', 66 | 'login_manual', 67 | string, 68 | 'home_page', 69 | ['ig.action.IsDarkModeEnabled'], 70 | ['bk.action.i32.Const', number], 71 | string, 72 | string, 73 | ['bk.action.map.Make', ['bk.action.array.Make'], ['bk.action.array.Make']], 74 | '', 75 | '', 76 | string, 77 | ], 78 | ], 79 | ['bk.action.map.Make', ['bk.action.array.Make'], ['bk.action.array.Make']], 80 | ], 81 | ], 82 | ], 83 | ], 84 | ], 85 | ['bk.action.i32.Const', number], 86 | ['bk.action.i32.Const', number], 87 | ], 88 | ['bk.action.tree.Make', ['bk.action.i32.Const', number]], 89 | ]; 90 | 91 | export type ThreadsLogin2FARequiredResponseBlok = [ 92 | 'bk.action.caa.PresentTwoFactorAuthFlow', 93 | ['bk.action.core.GetArg', 0], 94 | string, // JSON string of type ThreadsTwoFactorAuthFlowData 95 | ]; 96 | 97 | export type ThreadsLoginResponseData = { 98 | login_response: string; 99 | headers: string; 100 | cookies: string | null; 101 | }; 102 | 103 | export type LoginHeaders = { 104 | 'IG-Set-Authorization': string; // "Bearer IGT:2:SOME_BASE64_STRING", 105 | 'IG-Set-Password-Encryption-Key-Id': string; 106 | 'IG-Set-Password-Encryption-Pub-Key': string; // base64 encoded public key 107 | 'Access-Control-Expose-Headers': string; // "X-IG-Set-WWW-Claim", 108 | 'IG-Set-X-MID': string; 109 | 'X-IG-Reload-Proxy-Request-Info': string; // "{\\\"request_index\\\": number, \\\"view_name\\\": \\\"None.None\\\", \\\"uuid\\\": \\\"SOME_UUID\\\", \\\"sanitized_path\\\": \\\"/accounts/caa_ig_authentication_thrift_server/\\\"}", 110 | 'Cross-Origin-Opener-Policy': string; // "same-origin-allow-popups;report-to=\\\"coop\\\"", 111 | 'x-fb-endpoint': string; // "", 112 | 'X-Frame-Options': string; // "SAMEORIGIN", 113 | 'Cache-Control': string; // "private, no-cache, no-store, must-revalidate", 114 | Pragma: string; // "no-cache", 115 | Expires: string; // "Sat, 01 Jan 2000 00:00:00 GMT", 116 | 'Strict-Transport-Security': string; // "max-age=31536000", 117 | 'X-Content-Type-Options': string; // "nosniff", 118 | 'X-Xss-Protection': string; // "0", 119 | 'ig-set-ig-u-ds-user-id': number; 120 | 'ig-set-ig-u-rur': string; // "RVA", 121 | 'Cross-Origin-Embedder-Policy-Report-Only': string; // "require-corp;report-to=\\\"coep\\\"" 122 | }; 123 | 124 | export type LoginResponseData = { 125 | logged_in_user: { 126 | fbid_v2: number; 127 | text_post_app_take_a_break_setting: 0 | 1; 128 | is_using_unified_inbox_for_direct: boolean; 129 | show_insights_terms: boolean; 130 | allowed_commenter_type: 'any' | string; 131 | reel_auto_archive: 'unset' | string; 132 | can_hide_category: boolean; 133 | has_onboarded_to_text_post_app: boolean; 134 | text_post_app_joiner_number_label: string; 135 | pk: number; 136 | pk_id: string; 137 | username: string; 138 | full_name: string; 139 | is_private: boolean; 140 | third_party_downloads_enabled: 0 | 1; 141 | has_anonymous_profile_picture: boolean; 142 | supervision_info: { 143 | is_eligible_for_supervision: boolean; 144 | is_supervised_user: boolean; 145 | is_supervised_or_in_cooldown: boolean; 146 | has_guardian: boolean; 147 | is_guardian_user: boolean; 148 | is_supervised_by_viewer: boolean; 149 | is_guardian_of_viewer: boolean; 150 | has_stated_age: boolean; 151 | screen_time_daily_limit_seconds: null | number; 152 | screen_time_daily_limit_description: null | string; 153 | fc_url: string; 154 | quiet_time_intervals: null | number; 155 | is_quiet_time_feature_enabled: boolean; 156 | daily_time_limit_without_extensions_seconds: null | number; 157 | latest_valid_time_limit_extension_request: null | number; 158 | }; 159 | is_supervision_features_enabled: boolean; 160 | page_id: null | number; 161 | page_name: null | string; 162 | interop_messaging_user_fbid: number; 163 | biz_user_inbox_state: 0 | 1; 164 | nametag: { 165 | mode: number; 166 | gradient: number; 167 | emoji: string; 168 | selfie_sticker: number; 169 | }; 170 | has_placed_orders: boolean; 171 | total_igtv_videos: number; 172 | can_boost_post: boolean; 173 | can_see_organic_insights: boolean; 174 | wa_addressable: boolean; 175 | wa_eligibility: number; 176 | has_encrypted_backup: boolean; 177 | is_category_tappable: boolean; 178 | is_business: boolean; 179 | professional_conversion_suggested_account_type: number; 180 | account_type: 1; 181 | is_verified: boolean; 182 | profile_pic_id: string; 183 | profile_pic_url: string; 184 | is_call_to_action_enabled: null; 185 | category: null; 186 | account_badges: []; 187 | allow_contacts_sync: boolean; 188 | phone_number: string; 189 | country_code: number; 190 | national_number: number | string; 191 | }; 192 | session_flush_nonce: null | string; 193 | status: 'ok' | string; 194 | }; 195 | 196 | export type ThreadsTwoFactorAuthFlowData = { 197 | message?: string; 198 | two_factor_required?: boolean; 199 | two_factor_info?: TwoFactorInfo; 200 | phone_verification_settings?: PhoneVerificationSettings; 201 | status?: string; 202 | error_type?: string; 203 | }; 204 | 205 | export type PhoneVerificationSettings = { 206 | max_sms_count?: number; 207 | resend_sms_delay_sec?: number; 208 | robocall_count_down_time_sec?: number; 209 | robocall_after_max_sms?: boolean; 210 | }; 211 | 212 | export type TwoFactorInfo = { 213 | pk?: number; 214 | username?: string; 215 | sms_two_factor_on?: boolean; 216 | whatsapp_two_factor_on?: boolean; 217 | totp_two_factor_on?: boolean; 218 | eligible_for_multiple_totp?: boolean; 219 | obfuscated_phone_number?: string; 220 | obfuscated_phone_number_2?: string; 221 | two_factor_identifier?: string; 222 | show_messenger_code_option?: boolean; 223 | show_new_login_screen?: boolean; 224 | show_trusted_device_option?: boolean; 225 | should_opt_in_trusted_device_option?: boolean; 226 | pending_trusted_notification?: boolean; 227 | sms_not_allowed_reason?: null; 228 | trusted_notification_polling_nonce?: string; 229 | is_trusted_device?: boolean; 230 | device_id?: string; 231 | phone_verification_settings?: PhoneVerificationSettings; 232 | }; 233 | -------------------------------------------------------------------------------- /threads-api/src/cli.ts: -------------------------------------------------------------------------------- 1 | import { program } from 'commander'; 2 | 3 | import { ThreadsAPI } from './threads-api'; 4 | 5 | const Threads = new ThreadsAPI(); 6 | 7 | program.version(require('../package.json').version, '-v, --version', 'output the current version'); 8 | program.helpOption('-h, --help', 'display help for command'); 9 | 10 | program 11 | .command('help') 12 | .description('display help for command') 13 | .action(() => { 14 | program.outputHelp(); 15 | }); 16 | 17 | program 18 | .command('getUserIDfromUsername ') 19 | .alias('userid') 20 | .alias('uid') 21 | .alias('id') 22 | .description('get user ID from username') 23 | .action(async (username: string) => { 24 | const userID = await Threads.getUserIDfromUsername(username, { timeout: 10000 }); 25 | console.log(`User ID for ${username}: ${userID}`); 26 | }); 27 | 28 | program 29 | .command('getUserProfile [stringify]') 30 | .alias('userprofile') 31 | .alias('uprof') 32 | .alias('up') 33 | .description('get user profile') 34 | .action(async (username: string, userID: string, stringify?: boolean) => { 35 | const userProfile = await Threads.getUserProfile(username, userID, { timeout: 10000 }); 36 | console.log(stringify ? JSON.stringify(userProfile, null, 5) : userProfile); 37 | }); 38 | 39 | program 40 | .command('getUserProfileThreads [stringify]') 41 | .alias('uthreads') 42 | .alias('ut') 43 | .description('get user profile threads') 44 | .action(async (username: string, userID: string, stringify?: boolean) => { 45 | const userProfileThreads = await Threads.getUserProfileThreads(username, userID, { timeout: 10000 }); 46 | console.log(stringify ? JSON.stringify(userProfileThreads, null, 5) : userProfileThreads); 47 | }); 48 | 49 | program 50 | .command('getUserProfileReplies [stringify]') 51 | .alias('userreplies') 52 | .alias('ureplies') 53 | .alias('ur') 54 | .description('get user profile replies') 55 | .action(async (username: string, userID: string, stringify?: boolean) => { 56 | const userProfileReplies = await Threads.getUserProfileReplies(username, userID, { timeout: 10000 }); 57 | console.log(stringify ? JSON.stringify(userProfileReplies, null, 5) : userProfileReplies); 58 | }); 59 | 60 | program 61 | .command('getPostIDfromURL ') 62 | .alias('postid') 63 | .alias('pid') 64 | .alias('p') 65 | .description('get post ID from URL') 66 | .action(async (postURL: string) => { 67 | const postID = Threads.getPostIDfromURL(postURL); 68 | console.log(`Post ID for ${postURL}: ${postID}`); 69 | }); 70 | 71 | program 72 | .command('getThreads [stringify]') 73 | .alias('threads') 74 | .alias('t') 75 | .description('get threads') 76 | .action(async (postID: string, stringify?: boolean) => { 77 | const threads = await Threads.getThreads(postID, { timeout: 10000 }); 78 | console.log(threads, stringify ? JSON.stringify(threads, null, 5) : threads); 79 | }); 80 | 81 | program 82 | .command('getThreadLikers [stringify]') 83 | .alias('threadlikers') 84 | .alias('likers') 85 | .alias('l') 86 | .description('get thread likers') 87 | .action(async (postID: string, stringify?: boolean) => { 88 | const threadLikers = await Threads.getThreadLikers(postID, { timeout: 10000 }); 89 | console.log(stringify ? JSON.stringify(threadLikers, null, 5) : threadLikers); 90 | }); 91 | 92 | export { program }; 93 | -------------------------------------------------------------------------------- /threads-api/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_LSD_TOKEN = 'NjppQDEgONsU_1LCzrmp6q'; 2 | export const BASE_API_URL = 'https://i.instagram.com'; 3 | export const LOGIN_URL = `${BASE_API_URL}/api/v1/bloks/apps/com.bloks.www.bloks.caa.login.async.send_login_request/`; 4 | export const POST_URL = `${BASE_API_URL}/api/v1/media/configure_text_only_post/`; 5 | export const POST_WITH_IMAGE_URL = `${BASE_API_URL}/api/v1/media/configure_text_post_app_feed/`; 6 | export const POST_WITH_SIDECAR_URL = `${BASE_API_URL}/api/v1/media/configure_text_post_app_sidecar/`; 7 | export const LOGIN_EXPERIMENTS = 8 | 'ig_android_fci_onboarding_friend_search,ig_android_device_detection_info_upload,ig_android_account_linking_upsell_universe,ig_android_direct_main_tab_universe_v2,ig_android_allow_account_switch_once_media_upload_finish_universe,ig_android_sign_in_help_only_one_account_family_universe,ig_android_sms_retriever_backtest_universe,ig_android_direct_add_direct_to_android_native_photo_share_sheet,ig_android_spatial_account_switch_universe,ig_growth_android_profile_pic_prefill_with_fb_pic_2,ig_account_identity_logged_out_signals_global_holdout_universe,ig_android_prefill_main_account_username_on_login_screen_universe,ig_android_login_identifier_fuzzy_match,ig_android_mas_remove_close_friends_entrypoint,ig_android_shared_email_reg_universe,ig_android_video_render_codec_low_memory_gc,ig_android_custom_transitions_universe,ig_android_push_fcm,multiple_account_recovery_universe,ig_android_show_login_info_reminder_universe,ig_android_email_fuzzy_matching_universe,ig_android_one_tap_aymh_redesign_universe,ig_android_direct_send_like_from_notification,ig_android_suma_landing_page,ig_android_prefetch_debug_dialog,ig_android_smartlock_hints_universe,ig_android_black_out,ig_activation_global_discretionary_sms_holdout,ig_android_video_ffmpegutil_pts_fix,ig_android_multi_tap_login_new,ig_save_smartlock_universe,ig_android_caption_typeahead_fix_on_o_universe,ig_android_enable_keyboardlistener_redesign,ig_android_sign_in_password_visibility_universe,ig_android_nux_add_email_device,ig_android_direct_remove_view_mode_stickiness_universe,ig_android_hide_contacts_list_in_nux,ig_android_new_users_one_tap_holdout_universe,ig_android_ingestion_video_support_hevc_decoding,ig_android_mas_notification_badging_universe,ig_android_secondary_account_in_main_reg_flow_universe,ig_android_secondary_account_creation_universe,ig_android_account_recovery_auto_login,ig_android_pwd_encrytpion,ig_android_bottom_sheet_keyboard_leaks,ig_android_sim_info_upload,ig_android_mobile_http_flow_device_universe,ig_android_hide_fb_button_when_not_installed_universe,ig_android_account_linking_on_concurrent_user_session_infra_universe,ig_android_targeted_one_tap_upsell_universe,ig_android_gmail_oauth_in_reg,ig_android_account_linking_flow_shorten_universe,ig_android_vc_interop_use_test_igid_universe,ig_android_notification_unpack_universe,ig_android_registration_confirmation_code_universe,ig_android_device_based_country_verification,ig_android_log_suggested_users_cache_on_error,ig_android_reg_modularization_universe,ig_android_device_verification_separate_endpoint,ig_android_universe_noticiation_channels,ig_android_account_linking_universe,ig_android_hsite_prefill_new_carrier,ig_android_one_login_toast_universe,ig_android_retry_create_account_universe,ig_android_family_apps_user_values_provider_universe,ig_android_reg_nux_headers_cleanup_universe,ig_android_mas_ui_polish_universe,ig_android_device_info_foreground_reporting,ig_android_shortcuts_2019,ig_android_device_verification_fb_signup,ig_android_onetaplogin_optimization,ig_android_passwordless_account_password_creation_universe,ig_android_black_out_toggle_universe,ig_video_debug_overlay,ig_android_ask_for_permissions_on_reg,ig_assisted_login_universe,ig_android_security_intent_switchoff,ig_android_device_info_job_based_reporting,ig_android_add_account_button_in_profile_mas_universe,ig_android_add_dialog_when_delinking_from_child_account_universe,ig_android_passwordless_auth,ig_radio_button_universe_2,ig_android_direct_main_tab_account_switch,ig_android_recovery_one_tap_holdout_universe,ig_android_modularized_dynamic_nux_universe,ig_android_fb_account_linking_sampling_freq_universe,ig_android_fix_sms_read_lollipop,ig_android_access_flow_prefil'; 9 | export const SIGNATURE_KEY = '9193488027538fd3450b83b7d05286d4ca9599a0f7eeed90d8c85925698a05dc'; 10 | 11 | export const BASE_FOLLOW_PARAMS = { 12 | include_user_count: 'true', 13 | search_surface: 'barcelona_following_graph_page', 14 | }; 15 | 16 | export const IG_APP_ID = '3419628305025917'; 17 | export const BLOKS_VERSION = '5f56efad68e1edec7801f630b5c122704ec5378adbee6609a448f105f34a9c73'; 18 | export const FOLLOW_NAV_CHAIN = 19 | 'BarcelonaNavigationLogger$logNavigationCompleted$1:ig_text_feed_timeline:1:cold_start:1689291307.290::,BarcelonaNavigationLogger$logNavigationCompleted$1:ig_text_feed_timeline:2:button:1689291307.376::,BarcelonaNavigationLogger$logNavigationCompleted$1:ig_text_feed_profile:3:button:1689291309.4::,BarcelonaNavigationLogger$logNavigationCompleted$1:ig_text_feed_follow_list:4:button:1689291314.134::'; 20 | 21 | export const REPLY_CONTROL_OPTIONS = { 22 | everyone: 0, 23 | accounts_you_follow: 1, 24 | mentioned_only: 2, 25 | } as const; 26 | -------------------------------------------------------------------------------- /threads-api/src/dynamic-data.ts: -------------------------------------------------------------------------------- 1 | export const LATEST_ANDROID_APP_VERSION = '291.0.0.31.111'; 2 | -------------------------------------------------------------------------------- /threads-api/src/error.ts: -------------------------------------------------------------------------------- 1 | export class ThreadsAPIError extends Error { 2 | data: any; 3 | 4 | constructor(message: string, data: any) { 5 | super(message); 6 | this.name = this.constructor.name; 7 | this.data = data; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /threads-api/src/fetch.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | const fetch = globalThis.fetch; 4 | 5 | export { fetch }; 6 | -------------------------------------------------------------------------------- /threads-api/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './threads-api'; 2 | export * from './threads-types'; 3 | -------------------------------------------------------------------------------- /threads-api/src/threads-api.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosRequestConfig, AxiosResponseHeaders } from 'axios'; 2 | import { parseBloks, visitBloks } from 'bloks-tool'; 3 | import { BlokExpression } from 'bloks-tool/src/generated/bloks-tool.peggy'; 4 | import * as crypto from 'crypto'; 5 | import * as mimeTypes from 'mrmime'; 6 | import { v4 as uuidv4 } from 'uuid'; 7 | 8 | import { 9 | LoginHeaders, 10 | LoginResponseBlok, 11 | LoginResponseData, 12 | ThreadsLogin2FARequiredResponseBlok, 13 | ThreadsLoginResponseData, 14 | ThreadsTwoFactorAuthFlowData, 15 | } from './bloks-types'; 16 | import { 17 | BASE_API_URL, 18 | BASE_FOLLOW_PARAMS, 19 | BLOKS_VERSION, 20 | DEFAULT_LSD_TOKEN, 21 | FOLLOW_NAV_CHAIN, 22 | IG_APP_ID, 23 | LOGIN_EXPERIMENTS, 24 | POST_URL, 25 | POST_WITH_IMAGE_URL, 26 | POST_WITH_SIDECAR_URL, 27 | REPLY_CONTROL_OPTIONS, 28 | SIGNATURE_KEY, 29 | } from './constants'; 30 | import { LATEST_ANDROID_APP_VERSION } from './dynamic-data'; 31 | import { ThreadsAPIError } from './error'; 32 | import { AndroidDevice, Extensions, Story, Thread, ThreadsUser } from './threads-types'; 33 | import { StrictUnion } from './types/utils'; 34 | 35 | const generateDeviceID = () => { 36 | const deviceID = `android-${(Math.random() * 1e24).toString(36)}`; 37 | console.warn( 38 | `⚠️ WARNING: deviceID not provided, automatically generating device id '${deviceID}'`, 39 | 'Please save this device id and use it for future uses to prevent login issues.', 40 | 'You can provide this device id by passing it to the constructor or setting the THREADS_DEVICE_ID environment variable (.env file)', 41 | ); 42 | return deviceID; 43 | }; 44 | 45 | const authorizationHeader = 'IG-Set-Authorization'; 46 | 47 | export type ErrorResponse = { 48 | status: 'error'; // ? 49 | error_title: string; 50 | }; 51 | export type GetUserProfileResponse = { 52 | data: { 53 | userData: { 54 | user: ThreadsUser; 55 | }; 56 | }; 57 | extensions: Extensions; 58 | }; 59 | 60 | export type GetUserProfileThreadsResponse = { 61 | data: { 62 | mediaData?: { 63 | threads: Thread[]; 64 | }; 65 | }; 66 | extensions: Extensions; 67 | }; 68 | export type GetUserProfileThreadsPaginatedResponse = { 69 | status: 'ok'; 70 | next_max_id?: string; 71 | medias: []; 72 | threads: Thread[]; 73 | }; 74 | 75 | export type GetUserProfileLoggedInResponse = { 76 | users: ThreadsUser[]; 77 | status: 'ok'; 78 | }; 79 | 80 | export type GetUserProfileFollowPaginatedResponse = { 81 | status: 'ok'; 82 | users: ThreadsUser[]; 83 | big_list: boolean; // seems to be false when next_max_id === undefined 84 | page_size: number; 85 | next_max_id?: string; 86 | // has_more: boolean; this prop is confusing & always is false. use next_max_id === undefined for end of list 87 | should_limit_list_of_followers: boolean; 88 | }; 89 | 90 | export type GetThreadRepliesPaginatedResponse = { 91 | containing_thread: Thread; 92 | reply_threads: Thread[]; 93 | subling_threads: Thread[]; 94 | paging_tokens: { 95 | downward: string; 96 | }; 97 | downwards_thread_will_continue: boolean; 98 | target_post_reply_placeholder: string; 99 | status: 'ok'; 100 | }; 101 | 102 | export type GetNotificationsOptions = { 103 | feed_type: string; 104 | mark_as_seen: boolean; 105 | timezone_offset: number; 106 | timezone_name: string; 107 | selected_filters?: ThreadsAPI.NotificationFilter; 108 | max_id?: string; 109 | pagination_first_record_timestamp?: number; 110 | }; 111 | 112 | export interface GetNotificationsPagination { 113 | maxID?: string; 114 | firstRecordTimestamp?: number; 115 | } 116 | 117 | export type GetNotificationsPaginatedResponse = { 118 | counts: { 119 | [key: string]: any; 120 | }; 121 | last_checked: number; 122 | new_stories: Story[]; 123 | old_stories: Story[]; 124 | continuation_token: number; 125 | subscription: any; 126 | is_last_page: boolean; 127 | next_max_id: string; 128 | auto_load_more_enabled: boolean; 129 | pagination_first_record_timestamp: number; 130 | filters: any[]; 131 | status: 'ok'; 132 | }; 133 | 134 | export type GetRecommendedUsersPaginatedResponse = { 135 | users: ThreadsUser[]; 136 | paging_token: string; 137 | has_more: boolean; 138 | status: 'ok'; 139 | }; 140 | 141 | export type GetUserProfileThreadResponse = { 142 | data: { 143 | data: { 144 | containing_thread: Thread; 145 | reply_threads?: Thread[]; 146 | }; 147 | }; 148 | extensions: Extensions; 149 | }; 150 | 151 | export type GetThreadLikersResponse = { 152 | data: { 153 | likers: { 154 | users: ThreadsUser[]; 155 | }; 156 | }; 157 | extensions: Extensions; 158 | }; 159 | 160 | export type GetTimelineResponse = { 161 | num_results: number; 162 | more_available: boolean; 163 | auto_load_more_enabled: boolean; 164 | is_direct_v2_enabled: boolean; 165 | next_max_id: string; 166 | view_state_version: string; 167 | client_feed_changelist_applied: boolean; 168 | request_id: string; 169 | pull_to_refresh_window_ms: number; 170 | preload_distance: number; 171 | status: string; 172 | pagination_source: string; 173 | hide_like_and_view_counts: number; 174 | is_shell_response: boolean; 175 | items: Thread[]; 176 | feed_items_media_info: Array; 177 | }; 178 | 179 | export type InstagramImageUploadResponse = { 180 | upload_id: string; 181 | xsharing_nonces: {}; 182 | status: 'ok'; 183 | }; 184 | 185 | export type FriendshipStatusResponse = { 186 | friendship_status: { 187 | following: boolean; 188 | followed_by: boolean; 189 | blocking: boolean; 190 | muting: boolean; 191 | is_private: boolean; 192 | incoming_request: boolean; 193 | outgoing_request: boolean; 194 | text_post_app_pre_following: boolean; 195 | is_bestie: boolean; 196 | is_restricted: boolean; 197 | is_feed_favorite: boolean; 198 | is_eligible_to_subscribe: boolean; 199 | }; 200 | status: 'ok'; 201 | }; 202 | 203 | export const DEFAULT_DEVICE: AndroidDevice = { 204 | manufacturer: 'OnePlus', 205 | model: 'ONEPLUS+A3010', 206 | os_version: 25, 207 | os_release: '7.1.1', 208 | }; 209 | 210 | export declare namespace ThreadsAPI { 211 | type Options = { 212 | verbose?: boolean; 213 | token?: string; 214 | fbLSDToken?: string; 215 | 216 | noUpdateToken?: boolean; 217 | noUpdateLSD?: boolean; 218 | 219 | httpAgent?: AxiosRequestConfig['httpAgent']; 220 | httpsAgent?: AxiosRequestConfig['httpsAgent']; 221 | 222 | username?: string; 223 | password?: string; 224 | deviceID?: string; 225 | device?: AndroidDevice; 226 | userID?: string; 227 | locale?: string; 228 | maxRetries?: number; 229 | }; 230 | 231 | type ExternalImage = { 232 | path: string; 233 | }; 234 | 235 | type RawImage = { 236 | type: string; 237 | data: Buffer; 238 | }; 239 | 240 | type Image = string | ExternalImage | RawImage; 241 | 242 | type ImageAttachment = { 243 | image: Image; 244 | }; 245 | 246 | type SidecarAttachment = { 247 | sidecar: Image[]; 248 | }; 249 | 250 | type LinkAttachment = { 251 | url: string; 252 | }; 253 | 254 | type PostAttachment = StrictUnion; 255 | 256 | type PostReplyControl = keyof typeof REPLY_CONTROL_OPTIONS; 257 | 258 | enum NotificationFilter { 259 | MENTIONS = 'text_post_app_mentions', 260 | REPLIES = 'text_post_app_replies', 261 | VERIFIED = 'verified', 262 | } 263 | 264 | type PublishOptions = { 265 | text?: string; 266 | replyControl?: PostReplyControl; 267 | parentPostID?: string; 268 | quotedPostID?: string; 269 | attachment?: PostAttachment; 270 | /** @deprecated Use `attachment.url` instead. */ 271 | url?: string; 272 | /** @deprecated Use `attachment.image` instead. */ 273 | image?: Image; 274 | }; 275 | } 276 | 277 | interface UserIDQuerier { 278 | (userID: string, options?: AxiosRequestConfig): Promise; 279 | 280 | // deprecated 281 | (username: string, userID: string, options?: AxiosRequestConfig): Promise; 282 | } 283 | 284 | interface UserProfileQuerier { 285 | (userID: string, options?: AxiosRequestConfig): Promise; 286 | } 287 | 288 | interface PaginationUserIDQuerier { 289 | (userID: string, maxID?: string, options?: AxiosRequestConfig): Promise; 290 | } 291 | 292 | interface PaginationRepliesQuerier { 293 | (postID: string, maxID?: string, options?: AxiosRequestConfig): Promise; 294 | } 295 | 296 | interface PaginationNotificationsQuerier { 297 | ( 298 | filter?: ThreadsAPI.NotificationFilter, 299 | pagination?: GetNotificationsPagination, 300 | config?: AxiosRequestConfig, 301 | ): Promise; 302 | } 303 | 304 | interface PaginationRecommendedQuerier { 305 | (maxID?: string, config?: AxiosRequestConfig): Promise; 306 | } 307 | 308 | interface SearchQuerier { 309 | (query: string, count?: number, options?: AxiosRequestConfig): Promise; 310 | } 311 | 312 | export type SearchUsersResponse = { 313 | num_results: number; 314 | users: ThreadsUser[]; 315 | has_more: boolean; 316 | rank_token: string; 317 | status: 'ok'; 318 | }; 319 | 320 | export type PaginationAndSearchOptions = { 321 | maxID?: string; 322 | query?: string; 323 | }; 324 | 325 | interface PaginationAndSearchUserIDQuerier { 326 | (userID: string, params?: PaginationAndSearchOptions, options?: AxiosRequestConfig): Promise; 327 | } 328 | 329 | type LoginResponse = { token: string; userID: string | undefined }; 330 | 331 | export class ThreadsAPI { 332 | verbose: boolean; 333 | token?: string; 334 | fbLSDToken: string; 335 | 336 | noUpdateToken: boolean; 337 | noUpdateLSD: boolean; 338 | 339 | httpAgent?: AxiosRequestConfig['httpAgent']; 340 | httpsAgent?: AxiosRequestConfig['httpsAgent']; 341 | 342 | username?: string; 343 | password?: string; 344 | deviceID: string; 345 | device: AndroidDevice; 346 | userID?: string; 347 | locale: string; 348 | maxRetries: number; 349 | 350 | constructor(options: ThreadsAPI.Options = {}) { 351 | this.verbose = !!options.verbose; 352 | this.token = options.token; 353 | this.fbLSDToken = options.fbLSDToken ?? DEFAULT_LSD_TOKEN; 354 | 355 | this.noUpdateToken = !!options.noUpdateToken; 356 | this.noUpdateLSD = !!options.noUpdateLSD; 357 | 358 | this.httpAgent = options.httpAgent; 359 | this.httpsAgent = options.httpsAgent; 360 | 361 | this.username = options.username ?? process.env.THREADS_USERNAME; 362 | this.password = options.password ?? process.env.THREADS_PASSWORD; 363 | this.deviceID = (options.deviceID ?? process.env.THREADS_DEVICE_ID) || generateDeviceID(); 364 | this.device = options.device ?? DEFAULT_DEVICE; 365 | this.userID = options.userID; 366 | this.locale = options.locale ?? Intl.DateTimeFormat().resolvedOptions().locale; 367 | this.maxRetries = options.maxRetries ?? 1; 368 | } 369 | 370 | sign(payload: object | string) { 371 | const json = typeof payload === 'object' ? JSON.stringify(payload) : payload; 372 | const signature = crypto.createHmac('sha256', SIGNATURE_KEY).update(json).digest('hex'); 373 | return { 374 | ig_sig_key_version: 4, 375 | signed_body: `${signature}.${json}`, 376 | }; 377 | } 378 | 379 | syncLoginExperiments = async () => { 380 | const uid = uuidv4(); 381 | const data = { 382 | id: uid, 383 | experiments: LOGIN_EXPERIMENTS, 384 | }; 385 | try { 386 | const res = await axios.post(`${BASE_API_URL}/api/v1/qe/sync/`, this.sign(data), { 387 | httpAgent: this.httpAgent, 388 | httpsAgent: this.httpsAgent, 389 | headers: { 390 | ...this._getAppHeaders(), 391 | Authorization: undefined, 392 | 'Sec-Fetch-Site': 'same-origin', 393 | 'X-DEVICE-ID': uid, 394 | }, 395 | }); 396 | return res; 397 | } catch (error: any) { 398 | if (this.verbose) { 399 | console.log('[SYNC LOGIN EXPERIMENT FAILED]', error.response.data); 400 | } 401 | throw new Error('Sync login experiment failed'); 402 | } 403 | }; 404 | 405 | encryptPassword = async (password: string) => { 406 | // https://github.com/dilame/instagram-private-api/blob/master/src/repositories/account.repository.ts#L79 407 | const randKey = crypto.randomBytes(32); 408 | const iv = crypto.randomBytes(12); 409 | const { headers } = await this.syncLoginExperiments(); 410 | 411 | if (this.verbose) { 412 | console.log('[SYNC LOGIN EXPERIMENT HEADERS]', JSON.stringify(headers)); 413 | } 414 | 415 | const passwordEncryptionKeyID: number | undefined = headers['ig-set-password-encryption-key-id']; 416 | const passwordEncryptionPubKey: string | undefined = headers['ig-set-password-encryption-pub-key']; 417 | 418 | const rsaEncrypted = crypto.publicEncrypt( 419 | { 420 | key: Buffer.from(passwordEncryptionPubKey || '', 'base64').toString(), 421 | padding: crypto.constants.RSA_PKCS1_PADDING, 422 | }, 423 | randKey, 424 | ); 425 | const cipher = crypto.createCipheriv('aes-256-gcm', randKey, iv); 426 | const time = Math.floor(Date.now() / 1000).toString(); 427 | cipher.setAAD(Buffer.from(time)); 428 | 429 | const aesEncrypted = Buffer.concat([cipher.update(password, 'utf8'), cipher.final()]); 430 | const sizeBuffer = Buffer.alloc(2, 0); 431 | sizeBuffer.writeInt16LE(rsaEncrypted.byteLength, 0); 432 | 433 | const authTag = cipher.getAuthTag(); 434 | return { 435 | time, 436 | password: Buffer.concat([ 437 | Buffer.from([1, passwordEncryptionKeyID || 0]), 438 | iv, 439 | sizeBuffer, 440 | rsaEncrypted, 441 | authTag, 442 | aesEncrypted, 443 | ]).toString('base64'), 444 | }; 445 | }; 446 | 447 | login = async (onTwoFactorRequired?: () => void): Promise => { 448 | let retries = 0; 449 | 450 | const _login = async (): Promise => { 451 | if (this.verbose) { 452 | console.log('[LOGIN] Logging in...'); 453 | } 454 | const encryptedPassword = await this.encryptPassword(this.password!); 455 | 456 | const params = encodeURIComponent( 457 | JSON.stringify({ 458 | client_input_params: { 459 | password: `#PWD_INSTAGRAM:4:${encryptedPassword.time}:${encryptedPassword.password}`, 460 | contact_point: this.username, 461 | device_id: this.deviceID, 462 | }, 463 | server_params: { 464 | credential_type: 'password', 465 | device_id: this.deviceID, 466 | }, 467 | }), 468 | ); 469 | 470 | const bkClientContext = encodeURIComponent( 471 | JSON.stringify({ 472 | bloks_version: BLOKS_VERSION, 473 | styles_id: 'instagram', 474 | }), 475 | ); 476 | const requestConfig: AxiosRequestConfig = { 477 | httpAgent: this.httpAgent, 478 | httpsAgent: this.httpsAgent, 479 | method: 'POST', 480 | headers: this._getAppHeaders(), 481 | responseType: 'json', 482 | data: `params=${params}&bk_client_context=${bkClientContext}&bloks_versioning_id=${BLOKS_VERSION}`, 483 | }; 484 | 485 | type LoginRequestResponse = { 486 | layout: { 487 | bloks_payload: { 488 | data: any[]; 489 | props: { 490 | id: string; 491 | name: string; 492 | }; 493 | error_attribution: { 494 | logging_id: 'string'; 495 | }; 496 | tree: { 497 | 㐟: { 498 | '#': string; 499 | }; 500 | }; 501 | }; 502 | }; 503 | status: string; 504 | }; 505 | 506 | const { data } = await axios( 507 | `${BASE_API_URL}/api/v1/bloks/apps/com.bloks.www.bloks.caa.login.async.send_login_request/`, 508 | requestConfig, 509 | ); 510 | 511 | try { 512 | const rawBloks = data['layout']['bloks_payload']['tree']['㐟']['#']; 513 | 514 | const bloks = parseBloks(rawBloks); 515 | 516 | let result = 'unknown'; 517 | 518 | let relevantBlok!: BlokExpression; 519 | 520 | visitBloks(bloks, (blok) => { 521 | const [funcName, ...args] = blok; 522 | 523 | switch (funcName) { 524 | case 'ig.action.cdsdialog.OpenDialog': 525 | if (this.verbose) { 526 | console.log( 527 | '[login]: saw blok: ig.action.cdsdialog.OpenDialog', 528 | JSON.stringify(args, null, 2), 529 | ); 530 | } 531 | // wrong password... 532 | result = 'wrong_password'; 533 | relevantBlok = blok; 534 | return true; 535 | case 'bk.action.caa.PresentTwoFactorAuthFlow': 536 | if (this.verbose) { 537 | console.log('[login]: saw blok: bk.action.caa.PresentTwoFactorAuthFlow'); 538 | } 539 | // 2fa 540 | result = '2fa'; 541 | relevantBlok = blok; 542 | 543 | return true; 544 | case 'bk.action.caa.HandleLoginResponse': 545 | if (this.verbose) { 546 | console.log('[login]: saw blok: bk.action.caa.HandleLoginResponse'); 547 | } 548 | // success 549 | result = 'success'; 550 | relevantBlok = blok; 551 | 552 | return true; 553 | default: 554 | break; 555 | } 556 | 557 | return false; 558 | }); 559 | 560 | switch (result) { 561 | case 'wrong_password': 562 | throw new Error('Wrong password'); 563 | case '2fa': { 564 | const twoFactorBlok = relevantBlok as ThreadsLogin2FARequiredResponseBlok; 565 | 566 | const twoFactorData = JSON.parse( 567 | JSON.parse(`"${twoFactorBlok[2] as string}"`), 568 | ) as ThreadsTwoFactorAuthFlowData; 569 | 570 | if (onTwoFactorRequired) { 571 | onTwoFactorRequired(); 572 | } 573 | 574 | if (this.verbose) { 575 | console.debug('[login] Please approve the login request on your Instagram'); 576 | } 577 | 578 | const tokenFrom2fa = await new Promise<{ token: string; userID: string }>((resolve, reject) => { 579 | const statusUrl = '/api/v1/two_factor/check_trusted_notification_status/'; 580 | const verifyUrl = '/api/v1/accounts/two_factor_login/'; 581 | 582 | if (this.verbose) { 583 | console.debug('[login] Waiting for 2fa approval...'); 584 | } 585 | 586 | const twoFactorIdentifier = twoFactorData.two_factor_info?.two_factor_identifier!; 587 | const trusted_notification_polling_nonce = 588 | twoFactorData.two_factor_info?.trusted_notification_polling_nonce!; 589 | 590 | const intervalHandler = async () => { 591 | requestConfig.responseType = 'json'; 592 | requestConfig.data = new URLSearchParams({ 593 | two_factor_identifier: twoFactorIdentifier, 594 | username: this.username!, 595 | device_id: this.deviceID, 596 | trusted_notification_polling_nonces: JSON.stringify([trusted_notification_polling_nonce]), 597 | }).toString(); 598 | 599 | let axiosResponse = await axios<{ 600 | review_status: number; 601 | status: 'ok'; 602 | }>(`${BASE_API_URL}${statusUrl}`, requestConfig); 603 | 604 | if (axiosResponse.data.review_status === 1) { 605 | requestConfig.data = new URLSearchParams({ 606 | signed_body: 607 | 'SIGNATURE.' + 608 | JSON.stringify({ 609 | verification_code: '', 610 | two_factor_identifier: twoFactorIdentifier, 611 | username: this.username!, 612 | device_id: this.deviceID, 613 | trusted_notification_polling_nonces: JSON.stringify([ 614 | trusted_notification_polling_nonce, 615 | ]), 616 | verification_method: '4', 617 | }), 618 | }).toString(); 619 | 620 | const axiosResponse = await axios( 621 | `${BASE_API_URL}${verifyUrl}`, 622 | requestConfig, 623 | ); 624 | 625 | const headers = axiosResponse.headers as AxiosResponseHeaders; 626 | 627 | const token = 628 | (headers.get(authorizationHeader) as string)?.replace('Bearer IGT:2:', '') || ''; 629 | 630 | resolve({ 631 | token, 632 | userID: axiosResponse.data.logged_in_user.pk_id, 633 | }); 634 | } else { 635 | setTimeout(intervalHandler, 2_500); 636 | } 637 | }; 638 | 639 | intervalHandler(); 640 | }); 641 | 642 | if (!this.noUpdateToken) { 643 | if (this.verbose) { 644 | console.debug('[token] UPDATED', tokenFrom2fa.token); 645 | } 646 | this.token = tokenFrom2fa.token; 647 | } 648 | 649 | this.userID = tokenFrom2fa.userID; 650 | if (this.verbose) { 651 | console.debug('[userID] UPDATED', this.userID); 652 | } 653 | 654 | return tokenFrom2fa; 655 | } 656 | case 'success': { 657 | const successBlok = relevantBlok as LoginResponseBlok; 658 | 659 | const loginResponseData: ThreadsLoginResponseData = JSON.parse( 660 | JSON.parse(`"${successBlok[1][3]}"`), 661 | ); 662 | 663 | const actualLoginResponse: LoginResponseData = JSON.parse(loginResponseData.login_response); 664 | const loginResponseHeaders: LoginHeaders = JSON.parse(loginResponseData.headers); 665 | 666 | const authHeader = loginResponseHeaders[authorizationHeader]; 667 | 668 | const token = authHeader.split('Bearer IGT:2:')[1]; 669 | const userID = actualLoginResponse.logged_in_user.pk_id; 670 | 671 | if (!this.noUpdateToken) { 672 | if (this.verbose) { 673 | console.debug('[token] UPDATED', token); 674 | } 675 | this.token = token; 676 | } 677 | 678 | this.userID = userID; 679 | if (this.verbose) { 680 | console.debug('[userID] UPDATED', this.userID); 681 | } 682 | 683 | return { token, userID }; 684 | } 685 | case 'unknown': 686 | default: 687 | throw new Error('Unknown error'); 688 | } 689 | } catch (error) { 690 | if (this.verbose) { 691 | console.error('[LOGIN] Failed to login', error); 692 | } 693 | throw new Error('Login Failed'); 694 | } 695 | }; 696 | 697 | // try to login maxRetries times 698 | while (retries < this.maxRetries!) { 699 | try { 700 | return _login(); 701 | } catch (error) { 702 | if (this.verbose) { 703 | console.error(`[LOGIN] Failed to login, retrying... (${retries + 1}/${this.maxRetries})`); 704 | } 705 | const delay = Math.pow(2, retries) * 1000; // exponential backoff with base 2 706 | await new Promise((resolve) => setTimeout(resolve, delay)); 707 | retries++; 708 | } 709 | } 710 | 711 | throw new Error(`[LOGIN] Failed to login after ${this.maxRetries} retries`); 712 | }; 713 | 714 | _getUnAuthenticatedHeaders = () => ({ 715 | 'User-Agent': 716 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.199 Safari/537.36', 717 | 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 718 | }); 719 | 720 | _getDefaultUserDataHeaders = (username?: string) => ({ 721 | ...this._getUnAuthenticatedHeaders(), 722 | Host: 'www.threads.net', 723 | Accept: '*/*', 724 | 'Accept-Language': this.locale, 725 | 'cache-control': 'no-cache', 726 | Origin: 'https://www.threads.net', 727 | Pragma: 'no-cache', 728 | 'Sec-Fetch-Site': 'same-origin', 729 | 'X-Asbd-id': '129477', 730 | 'X-FB-Friendly-Name': 'BarcelonaProfileRootQuery', 731 | 'X-FB-Lsd': this.fbLSDToken, 732 | 'X-Ig-App-Id': '238260118697367', 733 | ...(!!username ? { Referer: `https://www.threads.net/@${username}` } : undefined), 734 | }); 735 | 736 | _getAppHeaders = () => ({ 737 | 'User-Agent': `Barcelona ${LATEST_ANDROID_APP_VERSION} Android`, 738 | 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 739 | ...(this.token && { Authorization: `Bearer IGT:2:${this.token}` }), 740 | }); 741 | 742 | _getInstaHeaders = () => ({ 743 | ...this._getAppHeaders(), 744 | 'X-Bloks-Is-Layout-Rtl': 'false', 745 | 'X-Bloks-Version-Id': BLOKS_VERSION, 746 | 'X-Ig-Android-Id': this.deviceID, 747 | 'X-Ig-App-Id': IG_APP_ID, 748 | 'Accept-Language': this.locale || 'en-US', 749 | ...(this.userID && { 'Ig-U-Ds-User-Id': this.userID, 'Ig-Intended-User-Id': this.userID }), 750 | ...(this.locale && { 751 | // strangely different from normal locale 752 | 'X-Ig-App-Locale': this.locale.replace('-', '_'), 753 | 'X-Ig-Device-Locale': this.locale.replace('-', '_'), 754 | 'X-Ig-Mapped-Locale': this.locale.replace('-', '_'), 755 | }), 756 | }); 757 | 758 | _getDefaultHeaders = (username?: string) => ({ 759 | ...this._getAppHeaders(), 760 | authority: 'www.threads.net', 761 | accept: '*/*', 762 | 'accept-language': this.locale, 763 | 'cache-control': 'no-cache', 764 | origin: 'https://www.threads.net', 765 | pragma: 'no-cache', 766 | 'Sec-Fetch-Site': 'same-origin', 767 | 'x-asbd-id': '129477', 768 | 'x-fb-lsd': this.fbLSDToken, 769 | 'x-ig-app-id': '238260118697367', 770 | ...(!!username ? { referer: `https://www.threads.net/@${username}` } : undefined), 771 | }); 772 | 773 | _getCleanedProfileHTML = async (url: string, username: string, options?: AxiosRequestConfig) => { 774 | const res = await axios.get(`${url}${username}`, { 775 | ...options, 776 | httpAgent: this.httpAgent, 777 | httpsAgent: this.httpsAgent, 778 | headers: { 779 | ...this._getDefaultHeaders(username), 780 | accept: 781 | 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', 782 | 'accept-language': 'ko,en;q=0.9,ko-KR;q=0.8,ja;q=0.7', 783 | Authorization: undefined, 784 | referer: 'https://www.instagram.com/', 785 | 'sec-fetch-dest': 'document', 786 | 'sec-fetch-mode': `navigate`, 787 | 'sec-fetch-site': `cross-site`, 788 | 'sec-fetch-user': `?1`, 789 | 'upgrade-insecure-requests': `1`, 790 | 'x-asbd-id': undefined, 791 | 'x-fb-lsd': undefined, 792 | 'x-ig-app-id': undefined, 793 | }, 794 | }); 795 | 796 | // let text: string = (await res.text()) 797 | let text: string = res.data; 798 | // remove ALL whitespaces from text 799 | text = text.replace(/\s/g, ''); 800 | // remove all newlines from text 801 | text = text.replace(/\n/g, ''); 802 | 803 | return text; 804 | }; 805 | 806 | getUserIDfromUsernameWithInstagram = async ( 807 | username: string, 808 | options?: AxiosRequestConfig, 809 | ): Promise => { 810 | const text = await this._getCleanedProfileHTML('https://www.instagram.com/', username, options); 811 | 812 | const userID: string | undefined = text.match(/"user_id":"(\d+)",/)?.[1]; 813 | const lsdToken: string | undefined = text.match(/"LSD",\[\],{"token":"(\w+)"},\d+\]/)?.[1]; 814 | 815 | if (!this.noUpdateLSD && !!lsdToken) { 816 | this.fbLSDToken = lsdToken; 817 | if (this.verbose) { 818 | console.debug('[fbLSDToken] UPDATED', this.fbLSDToken); 819 | } 820 | } 821 | 822 | return userID; 823 | }; 824 | 825 | getUserIDfromUsername = async ( 826 | username: string, 827 | options?: AxiosRequestConfig, 828 | ): Promise => { 829 | const text = await this._getCleanedProfileHTML('https://www.threads.net/@', username, options); 830 | 831 | const userID: string | undefined = text.match(/"user_id":"(\d+)"/)?.[1]; 832 | const lsdToken: string | undefined = text.match(/"LSD",\[\],{"token":"(\w+)"},\d+\]/)?.[1]; 833 | 834 | if (!userID) { 835 | return this.getUserIDfromUsernameWithInstagram(username, options); 836 | } 837 | 838 | if (!this.noUpdateLSD && !!lsdToken) { 839 | this.fbLSDToken = lsdToken; 840 | if (this.verbose) { 841 | console.debug('[fbLSDToken] UPDATED', this.fbLSDToken); 842 | } 843 | } 844 | 845 | return userID; 846 | }; 847 | getCurrentUserID = async (options?: AxiosRequestConfig) => { 848 | if (this.userID) { 849 | if (this.verbose) { 850 | console.debug('[userID] USING', this.userID); 851 | } 852 | return this.userID; 853 | } 854 | if (!this.username) { 855 | throw new Error('username is not defined'); 856 | } 857 | try { 858 | this.userID = await this.getUserIDfromUsername(this.username, options); 859 | if (this.verbose) { 860 | console.debug('[userID] UPDATED', this.userID); 861 | } 862 | return this.userID; 863 | } catch (e) { 864 | if (this.verbose) { 865 | console.error('[userID] Failed to fetch userID, Fallbacking to login', e); 866 | } 867 | const { userID } = await this.login(); 868 | return userID; 869 | } 870 | }; 871 | 872 | _requestQuery = ( 873 | url: string, 874 | data: Record, 875 | options?: AxiosRequestConfig, 876 | ) => { 877 | Object.keys(data).forEach((key) => data[key] === undefined && delete data[key]); 878 | return axios.post(url, new URLSearchParams(data as Record), { 879 | httpAgent: this.httpAgent, 880 | httpsAgent: this.httpsAgent, 881 | headers: this._getDefaultHeaders(), 882 | ...options, 883 | }); 884 | }; 885 | 886 | _requestUserDataQuery = ( 887 | url: string, 888 | data: Record, 889 | options?: AxiosRequestConfig, 890 | ) => { 891 | Object.keys(data).forEach((key) => data[key] === undefined && delete data[key]); 892 | return axios.post(url, new URLSearchParams(data as Record), { 893 | httpAgent: this.httpAgent, 894 | httpsAgent: this.httpsAgent, 895 | headers: this._getDefaultUserDataHeaders(), 896 | ...options, 897 | }); 898 | }; 899 | 900 | _destructureFromUserIDQuerier = (params: any) => { 901 | const typedParams = params as 902 | | [string] 903 | | [string, AxiosRequestConfig | undefined] 904 | | [string, string] // old 905 | | [string, string, AxiosRequestConfig | undefined]; // old 906 | let userID: string; 907 | let options: AxiosRequestConfig | undefined; 908 | if (typeof typedParams[0] === 'string' && typeof typedParams[1] === 'string') { 909 | // old 910 | // username = typedParams[0] 911 | userID = typedParams[1]; 912 | options = typedParams[2]; 913 | } else { 914 | userID = typedParams[0]; 915 | options = typedParams[1] as AxiosRequestConfig | undefined; 916 | } 917 | return { userID, options }; 918 | }; 919 | 920 | getUserProfile: UserIDQuerier = async (...params) => { 921 | const { userID, options } = this._destructureFromUserIDQuerier(params); 922 | if (this.verbose) { 923 | console.debug('[fbLSDToken] USING', this.fbLSDToken); 924 | } 925 | 926 | const res = await this._requestUserDataQuery( 927 | 'https://www.threads.net/api/graphql', 928 | { 929 | lsd: this.fbLSDToken, 930 | variables: JSON.stringify({ userID }), 931 | doc_id: '23996318473300828', 932 | }, 933 | options, 934 | ); 935 | const user = res.data.data.userData.user; 936 | return user; 937 | }; 938 | 939 | getUserProfileLoggedIn: UserProfileQuerier = async ( 940 | userID, 941 | options = {}, 942 | ): Promise => { 943 | let data: GetUserProfileLoggedInResponse | ErrorResponse | undefined = undefined; 944 | try { 945 | const res = await this._fetchAuthGetRequest( 946 | `${BASE_API_URL}/api/v1/users/${userID}/info?is_prefetch=false&entry_point=profile&from_module=ProfileViewModel`, 947 | options, 948 | ); 949 | data = res.data; 950 | } catch (error: any) { 951 | data = error.response?.data; 952 | } 953 | if (data?.status !== 'ok') { 954 | if (this.verbose) { 955 | console.log('[USER PROFILE] Failed to fetch', data); 956 | } 957 | throw new ThreadsAPIError('Failed to fetch user profile: ' + JSON.stringify(data), data); 958 | } 959 | return data; 960 | }; 961 | 962 | getUserProfileThreads: UserIDQuerier = async (...params) => { 963 | const { userID, options } = this._destructureFromUserIDQuerier(params); 964 | if (this.verbose) { 965 | console.debug('[fbLSDToken] USING', this.fbLSDToken); 966 | } 967 | 968 | const res = await this._requestUserDataQuery( 969 | 'https://www.threads.net/api/graphql', 970 | { 971 | lsd: this.fbLSDToken, 972 | variables: JSON.stringify({ userID }), 973 | doc_id: '6232751443445612', 974 | }, 975 | options, 976 | ); 977 | const threads = res.data.data?.mediaData?.threads || []; 978 | return threads; 979 | }; 980 | 981 | getUserProfileThreadsLoggedIn: PaginationUserIDQuerier = async ( 982 | userID, 983 | maxID = '', 984 | options = {}, 985 | ): Promise => { 986 | let data: GetUserProfileThreadsPaginatedResponse | ErrorResponse | undefined = undefined; 987 | try { 988 | const res = await this._fetchAuthGetRequest( 989 | `${BASE_API_URL}/api/v1/text_feed/${userID}/profile/${maxID ? `?max_id=${maxID}` : ''}`, 990 | options, 991 | ); 992 | data = res.data; 993 | } catch (error: any) { 994 | data = error.response?.data; 995 | } 996 | if (data?.status !== 'ok') { 997 | if (this.verbose) { 998 | console.log('[USER THREADS] Failed to fetch', data); 999 | } 1000 | throw new ThreadsAPIError('Failed to fetch user threads: ' + JSON.stringify(data), data); 1001 | } 1002 | return data; 1003 | }; 1004 | 1005 | //NOTE: REFERER URL FOR REPLIES IS DIFFERENT WHEN NOT LOGGED IN 1006 | _getDefaultRepliesHeaders = (username?: string) => ({ 1007 | ...this._getUnAuthenticatedHeaders(), 1008 | Host: 'www.threads.net', 1009 | Accept: '*/*', 1010 | 'Accept-Language': this.locale, 1011 | 'cache-control': 'no-cache', 1012 | Origin: 'https://www.threads.net', 1013 | Pragma: 'no-cache', 1014 | 'Sec-Fetch-Site': 'same-origin', 1015 | 'X-Asbd-id': '129477', 1016 | 'X-FB-Friendly-Name': 'BarcelonaProfileProfileRepliesTabQuery', 1017 | 'X-FB-Lsd': this.fbLSDToken, 1018 | 'X-Ig-App-Id': '238260118697367', 1019 | ...(!!username ? { Referer: `https://www.threads.net/@${username}/replies` } : undefined), 1020 | }); 1021 | 1022 | _requestRepliesQuery = ( 1023 | url: string, 1024 | data: Record, 1025 | options?: AxiosRequestConfig, 1026 | ) => { 1027 | Object.keys(data).forEach((key) => data[key] === undefined && delete data[key]); 1028 | return axios.post(url, new URLSearchParams(data as Record), { 1029 | httpAgent: this.httpAgent, 1030 | httpsAgent: this.httpsAgent, 1031 | headers: this._getDefaultRepliesHeaders(), 1032 | ...options, 1033 | }); 1034 | }; 1035 | 1036 | getUserProfileReplies: UserIDQuerier = async (...params) => { 1037 | const { userID, options } = this._destructureFromUserIDQuerier(params); 1038 | if (this.verbose) { 1039 | console.debug('[fbLSDToken] USING', this.fbLSDToken); 1040 | } 1041 | 1042 | const res = await this._requestRepliesQuery( 1043 | 'https://www.threads.net/api/graphql', 1044 | { 1045 | lsd: this.fbLSDToken, 1046 | variables: JSON.stringify({ userID }), 1047 | doc_id: '6684830921547925', 1048 | }, 1049 | options, 1050 | ); 1051 | const mediaData = res.data.data.mediaData; 1052 | 1053 | // Manually assert the type of threads to ensure TypeScript recognizes it correctly 1054 | const threads = (mediaData?.threads || []) as Thread[]; 1055 | return threads; 1056 | }; 1057 | 1058 | getUserProfileRepliesLoggedIn: PaginationUserIDQuerier = async ( 1059 | userID, 1060 | maxID = '', 1061 | options = {}, 1062 | ): Promise => { 1063 | if (!this.token) { 1064 | await this.getToken(); 1065 | } 1066 | if (!this.token) { 1067 | throw new Error('Token not found'); 1068 | } 1069 | 1070 | let data: GetUserProfileThreadsPaginatedResponse | ErrorResponse | undefined = undefined; 1071 | try { 1072 | const res = await this._fetchAuthGetRequest( 1073 | `${BASE_API_URL}/api/v1/text_feed/${userID}/profile/replies/${maxID ? `?max_id=${maxID}` : ''}`, 1074 | options, 1075 | ); 1076 | data = res.data; 1077 | } catch (error: any) { 1078 | data = error.response?.data; 1079 | } 1080 | if (data?.status !== 'ok') { 1081 | if (this.verbose) { 1082 | console.log('[USER REPLIES] Failed to fetch', data); 1083 | } 1084 | throw new ThreadsAPIError('Failed to fetch user replies: ' + JSON.stringify(data), data); 1085 | } 1086 | return data; 1087 | }; 1088 | 1089 | getUserFollowers: PaginationAndSearchUserIDQuerier = async ( 1090 | userID, 1091 | { maxID, query } = {}, 1092 | options?: AxiosRequestConfig, 1093 | ) => { 1094 | let data: GetUserProfileFollowPaginatedResponse | ErrorResponse | undefined = undefined; 1095 | 1096 | const params = new URLSearchParams(BASE_FOLLOW_PARAMS); 1097 | 1098 | if (maxID) params.append('max_id', maxID); 1099 | if (query) params.append('query', query); 1100 | 1101 | try { 1102 | const res = await this._fetchAuthGetRequest( 1103 | `${BASE_API_URL}/api/v1/friendships/${userID}/followers/?${params.toString()}`, 1104 | { 1105 | ...options, 1106 | headers: { 'X-Ig-Nav-Chain': FOLLOW_NAV_CHAIN, ...options?.headers }, 1107 | }, 1108 | ); 1109 | data = res.data; 1110 | } catch (error: any) { 1111 | data = error.response?.data; 1112 | } 1113 | if (data?.status !== 'ok') { 1114 | if (this.verbose) { 1115 | console.log('[USER FOLLOWERS] Failed to fetch', data); 1116 | } 1117 | throw new ThreadsAPIError('Failed to fetch user followers: ' + JSON.stringify(data), data); 1118 | } 1119 | return data; 1120 | }; 1121 | 1122 | getUserFollowings: PaginationAndSearchUserIDQuerier = async ( 1123 | userID, 1124 | { maxID, query } = {}, 1125 | options?: AxiosRequestConfig, 1126 | ) => { 1127 | let data: GetUserProfileFollowPaginatedResponse | ErrorResponse | undefined = undefined; 1128 | 1129 | const params = new URLSearchParams(BASE_FOLLOW_PARAMS); 1130 | 1131 | if (maxID) params.append('max_id', maxID); 1132 | if (query) params.append('query', query); 1133 | 1134 | try { 1135 | const res = await this._fetchAuthGetRequest( 1136 | `${BASE_API_URL}/api/v1/friendships/${userID}/following/?${params.toString()}`, 1137 | { 1138 | ...options, 1139 | headers: { 'X-Ig-Nav-Chain': FOLLOW_NAV_CHAIN, ...options?.headers }, 1140 | }, 1141 | ); 1142 | data = res.data; 1143 | } catch (error: any) { 1144 | data = error.response?.data; 1145 | } 1146 | if (data?.status !== 'ok') { 1147 | if (this.verbose) { 1148 | console.log('[USER FOLLOWINGS] Failed to fetch', data); 1149 | } 1150 | throw new ThreadsAPIError('Failed to fetch user followings: ' + JSON.stringify(data), data); 1151 | } 1152 | return data; 1153 | }; 1154 | 1155 | getPostIDfromThreadID = (threadID: string): string => { 1156 | threadID = threadID.split('?')[0]; 1157 | threadID = threadID.replace(/\s/g, ''); 1158 | threadID = threadID.replace(/\//g, ''); 1159 | const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; 1160 | let postID = 0n; 1161 | for (const letter of threadID) { 1162 | postID = postID * 64n + BigInt(alphabet.indexOf(letter)); 1163 | } 1164 | return postID.toString(); 1165 | }; 1166 | 1167 | getPostIDfromURL = (postURL: string): string => { 1168 | let threadID = postURL?.split('?')[0]; 1169 | if (threadID?.endsWith('/')) { 1170 | threadID = threadID.slice(0, -1); 1171 | } 1172 | threadID = threadID?.split('/').pop() || ''; 1173 | return this.getPostIDfromThreadID((threadID as any) || ''); 1174 | }; 1175 | 1176 | getThreads = async (postID: string, options?: AxiosRequestConfig) => { 1177 | if (this.verbose) { 1178 | console.debug('[fbLSDToken] USING', this.fbLSDToken); 1179 | } 1180 | 1181 | const res = await this._requestUserDataQuery( 1182 | 'https://www.threads.net/api/graphql', 1183 | { 1184 | lsd: this.fbLSDToken, 1185 | variables: JSON.stringify({ postID }), 1186 | doc_id: '5587632691339264', 1187 | }, 1188 | 1189 | options, 1190 | ); 1191 | const thread = res.data.data.data; 1192 | return thread; 1193 | }; 1194 | 1195 | getThreadsLoggedIn: PaginationRepliesQuerier = async ( 1196 | postID, 1197 | maxID = '', 1198 | options = {}, 1199 | ): Promise => { 1200 | let data: GetThreadRepliesPaginatedResponse | ErrorResponse | undefined = undefined; 1201 | try { 1202 | const res = await this._fetchAuthGetRequest( 1203 | `${BASE_API_URL}/api/v1/text_feed/${postID}/replies/${maxID ? `?paging_token=${maxID}` : ''}`, 1204 | options, 1205 | ); 1206 | data = res.data; 1207 | } catch (error: any) { 1208 | data = error.response?.data; 1209 | } 1210 | if (data?.status !== 'ok') { 1211 | if (this.verbose) { 1212 | console.log('[USER FEED] Failed to fetch', data); 1213 | } 1214 | throw new ThreadsAPIError('Failed to fetch user feed: ' + JSON.stringify(data), data); 1215 | } 1216 | return data; 1217 | }; 1218 | 1219 | getThreadLikers = async (postID: string, options?: AxiosRequestConfig) => { 1220 | if (this.verbose) { 1221 | console.debug('[fbLSDToken] USING', this.fbLSDToken); 1222 | } 1223 | 1224 | const res = await this._requestUserDataQuery( 1225 | 'https://www.threads.net/api/graphql', 1226 | { 1227 | lsd: this.fbLSDToken, 1228 | variables: JSON.stringify({ mediaID: postID }), 1229 | doc_id: '9360915773983802', 1230 | }, 1231 | 1232 | options, 1233 | ); 1234 | const likers = res.data.data.likers; 1235 | return likers; 1236 | }; 1237 | 1238 | getTimeline = async (maxID: string = '', options?: AxiosRequestConfig): Promise => { 1239 | if (!this.token && (!this.username || !this.password)) { 1240 | throw new Error('Username or password not set'); 1241 | } 1242 | 1243 | const token = await this.getToken(); 1244 | if (!token) { 1245 | throw new Error('Token not found'); 1246 | } 1247 | 1248 | try { 1249 | const res = await this._requestQuery( 1250 | `${BASE_API_URL}/api/v1/feed/text_post_app_timeline/`, 1251 | { pagination_source: 'text_post_feed_threads', max_id: maxID || undefined }, 1252 | { ...options, headers: this._getAppHeaders() }, 1253 | ); 1254 | return res.data; 1255 | } catch (error: any) { 1256 | if (this.verbose) { 1257 | console.log('[TIMELINE FETCH FAILED]', error.response.data); 1258 | } 1259 | throw new Error('Failed to fetch timeline'); 1260 | } 1261 | }; 1262 | 1263 | _fetchAuthGetRequest = async ( 1264 | url: string, 1265 | options?: AxiosRequestConfig, 1266 | ) => { 1267 | const token = await this.getToken(); 1268 | if (!token) { 1269 | throw new Error('Token not found'); 1270 | } 1271 | const res = await axios.get(url, { 1272 | ...options, 1273 | headers: { 1274 | ...this._getInstaHeaders(), 1275 | ...options?.headers, 1276 | }, 1277 | }); 1278 | return res; 1279 | }; 1280 | 1281 | _toggleAuthPostRequest = async ( 1282 | url: string, 1283 | data?: Record, 1284 | options?: AxiosRequestConfig, 1285 | ) => { 1286 | const token = await this.getToken(); 1287 | if (!token) { 1288 | throw new Error('Token not found'); 1289 | } 1290 | const res = await axios.post(url, !data ? undefined : new URLSearchParams(data), { 1291 | ...options, 1292 | httpAgent: this.httpAgent, 1293 | httpsAgent: this.httpsAgent, 1294 | headers: this._getDefaultHeaders(), 1295 | }); 1296 | return res; 1297 | }; 1298 | like = async (postID: string, options?: AxiosRequestConfig) => { 1299 | const userID = await this.getCurrentUserID(); 1300 | const res = await this._toggleAuthPostRequest<{ status: 'ok' | string }>( 1301 | `${BASE_API_URL}/api/v1/media/${postID}_${userID}/like/`, 1302 | undefined, 1303 | options, 1304 | ); 1305 | return res.data; 1306 | }; 1307 | unlike = async (postID: string, options?: AxiosRequestConfig) => { 1308 | const userID = await this.getCurrentUserID(); 1309 | const res = await this._toggleAuthPostRequest<{ status: 'ok' | string }>( 1310 | `${BASE_API_URL}/api/v1/media/${postID}_${userID}/unlike/`, 1311 | undefined, 1312 | options, 1313 | ); 1314 | return res.data; 1315 | }; 1316 | follow = async (userID: string, options?: AxiosRequestConfig) => { 1317 | const res = await this._toggleAuthPostRequest( 1318 | `${BASE_API_URL}/api/v1/friendships/create/${userID}/`, 1319 | undefined, 1320 | options, 1321 | ); 1322 | if (this.verbose) { 1323 | console.debug('[FOLLOW]', res.data); 1324 | } 1325 | return res.data; 1326 | }; 1327 | unfollow = async (userID: string, options?: AxiosRequestConfig) => { 1328 | const res = await this._toggleAuthPostRequest( 1329 | `${BASE_API_URL}/api/v1/friendships/destroy/${userID}/`, 1330 | undefined, 1331 | options, 1332 | ); 1333 | if (this.verbose) { 1334 | console.debug('[UNFOLLOW]', res.data); 1335 | } 1336 | return res.data; 1337 | }; 1338 | repost = async (postID: string, options?: AxiosRequestConfig) => { 1339 | const res = await this._toggleAuthPostRequest( 1340 | `${BASE_API_URL}/api/v1/repost/create_repost/`, 1341 | { media_id: postID }, 1342 | options, 1343 | ); 1344 | if (this.verbose) { 1345 | console.debug('[REPOST]', res.data); 1346 | } 1347 | return res.data; 1348 | }; 1349 | unrepost = async (originalPostID: string, options?: AxiosRequestConfig) => { 1350 | const res = await this._toggleAuthPostRequest( 1351 | `${BASE_API_URL}/api/v1/repost/delete_text_app_repost/`, 1352 | { original_media_id: originalPostID }, 1353 | options, 1354 | ); 1355 | if (this.verbose) { 1356 | console.debug('[UNREPOST]', res.data); 1357 | } 1358 | return res.data; 1359 | }; 1360 | mute = async ( 1361 | muteOptions: { postID?: string; userID: string }, 1362 | options?: AxiosRequestConfig, 1363 | ): Promise => { 1364 | const url = `${BASE_API_URL}/api/v1/friendships/mute_posts_or_story_from_follow/`; 1365 | let data = { 1366 | _uid: this.userID, 1367 | _uuid: this.deviceID, 1368 | container_module: 'ig_text_feed_timeline', 1369 | } as { 1370 | media_id?: string; 1371 | _uid: string; 1372 | _uuid: string; 1373 | container_module: string; 1374 | target_posts_author_id: string; 1375 | }; 1376 | 1377 | if (muteOptions.postID) { 1378 | data.media_id = String(muteOptions.postID); 1379 | } 1380 | data.target_posts_author_id = String(muteOptions.userID); 1381 | 1382 | const payload = { 1383 | signed_body: `SIGNATURE.${encodeURIComponent(JSON.stringify(data))}`, 1384 | }; 1385 | 1386 | const res = await this._toggleAuthPostRequest(url, payload, options); 1387 | if (this.verbose) { 1388 | console.debug('[MUTE]', res.data); 1389 | } 1390 | return res.data; 1391 | }; 1392 | unmute = async ( 1393 | muteOptions: { postID?: string; userID: string }, 1394 | options?: AxiosRequestConfig, 1395 | ): Promise => { 1396 | const url = `${BASE_API_URL}/api/v1/friendships/unmute_posts_or_story_from_follow/`; 1397 | let data = { 1398 | _uid: this.userID, 1399 | _uuid: this.deviceID, 1400 | container_module: 'ig_text_feed_timeline', 1401 | } as { 1402 | media_id?: string; 1403 | _uid: string; 1404 | _uuid: string; 1405 | container_module: string; 1406 | target_posts_author_id: string; 1407 | }; 1408 | 1409 | if (muteOptions.postID) { 1410 | data.media_id = String(muteOptions.postID); 1411 | } 1412 | data.target_posts_author_id = String(muteOptions.userID); 1413 | 1414 | const payload = { 1415 | signed_body: `SIGNATURE.${encodeURIComponent(JSON.stringify(data))}`, 1416 | }; 1417 | 1418 | const res = await this._toggleAuthPostRequest(url, payload, options); 1419 | if (this.verbose) { 1420 | console.debug('[UNMUTE]', res.data); 1421 | } 1422 | return res.data; 1423 | }; 1424 | block = async (userID: string, options?: AxiosRequestConfig): Promise => { 1425 | const url = `${BASE_API_URL}/api/v1/friendships/block/${userID}/`; 1426 | let data = { 1427 | surface: 'ig_text_feed_timeline', 1428 | is_auto_block_enabled: true, 1429 | user_id: userID, 1430 | _uid: this.userID, 1431 | _uuid: this.deviceID, 1432 | }; 1433 | 1434 | const payload = { 1435 | signed_body: `SIGNATURE.${encodeURIComponent(JSON.stringify(data))}`, 1436 | }; 1437 | 1438 | const res = await this._toggleAuthPostRequest(url, payload, options); 1439 | if (this.verbose) { 1440 | console.debug('[MUTE]', res.data); 1441 | } 1442 | return res.data; 1443 | }; 1444 | unblock = async (userID: string, options?: AxiosRequestConfig): Promise => { 1445 | const url = `${BASE_API_URL}/api/v1/friendships/unblock/${userID}/`; 1446 | let data = { 1447 | user_id: userID, 1448 | _uid: this.userID, 1449 | _uuid: this.deviceID, 1450 | container_module: 'ig_text_feed_timeline', 1451 | }; 1452 | 1453 | const payload = { 1454 | signed_body: `SIGNATURE.${encodeURIComponent(JSON.stringify(data))}`, 1455 | }; 1456 | 1457 | const res = await this._toggleAuthPostRequest(url, payload, options); 1458 | if (this.verbose) { 1459 | console.debug('[MUTE]', res.data); 1460 | } 1461 | return res.data; 1462 | }; 1463 | 1464 | getNotifications: PaginationNotificationsQuerier = async ( 1465 | filter, 1466 | pagination, 1467 | options = {}, 1468 | ): Promise => { 1469 | let params: GetNotificationsOptions = { 1470 | feed_type: 'all', 1471 | mark_as_seen: false, 1472 | timezone_offset: -25200, 1473 | timezone_name: 'America%2FLos_Angeles', 1474 | }; 1475 | 1476 | if (filter) { 1477 | params.selected_filters = filter; 1478 | } 1479 | 1480 | if (pagination) { 1481 | params.max_id = pagination.maxID; 1482 | params.pagination_first_record_timestamp = pagination.firstRecordTimestamp; 1483 | } 1484 | 1485 | const queryString = Object.entries(params) 1486 | .map(([key, value]) => key + '=' + value) 1487 | .join('&'); 1488 | 1489 | let data: GetNotificationsPaginatedResponse | ErrorResponse | undefined = undefined; 1490 | try { 1491 | const res = await this._fetchAuthGetRequest( 1492 | `${BASE_API_URL}/api/v1/text_feed/text_app_notifications/?${queryString}`, 1493 | options, 1494 | ); 1495 | data = res.data; 1496 | } catch (error: any) { 1497 | data = error.response?.data; 1498 | } 1499 | if (data?.status !== 'ok') { 1500 | if (this.verbose) { 1501 | console.log('[NOTIFICATIONS] Failed to fetch', data); 1502 | } 1503 | throw new ThreadsAPIError('Failed to fetch notifications: ' + JSON.stringify(data), data); 1504 | } 1505 | return data; 1506 | }; 1507 | 1508 | setNotificationsSeen = async (options?: AxiosRequestConfig): Promise => { 1509 | const url = `${BASE_API_URL}/api/v1/text_feed/text_app_inbox_seen/`; 1510 | const payload = { 1511 | _uuid: `${this.userID}`, 1512 | }; 1513 | const res = await this._toggleAuthPostRequest(url, payload, options); 1514 | if (this.verbose) { 1515 | console.debug('[SET_NOTIFICATIONS_SEEN]', res.data); 1516 | } 1517 | return res.data; 1518 | }; 1519 | 1520 | searchUsers: SearchQuerier = async ( 1521 | query, 1522 | count = 30, 1523 | options = {}, 1524 | ): Promise => { 1525 | let params = { 1526 | q: query, 1527 | count, 1528 | }; 1529 | 1530 | const queryString = Object.entries(params) 1531 | .map(([key, value]) => key + '=' + value) 1532 | .join('&'); 1533 | 1534 | let data: SearchUsersResponse | ErrorResponse | undefined = undefined; 1535 | try { 1536 | const res = await this._fetchAuthGetRequest( 1537 | `${BASE_API_URL}/api/v1/users/search/?${queryString}`, 1538 | options, 1539 | ); 1540 | data = res.data; 1541 | } catch (error: any) { 1542 | data = error.response?.data; 1543 | } 1544 | if (data?.status !== 'ok') { 1545 | if (this.verbose) { 1546 | console.log('[USER SEARCH] Failed to fetch', data); 1547 | } 1548 | throw new ThreadsAPIError('Failed to fetch user search results: ' + JSON.stringify(data), data); 1549 | } 1550 | return data; 1551 | }; 1552 | 1553 | getRecommendedUsers: PaginationRecommendedQuerier = async ( 1554 | maxID = '', 1555 | options = {}, 1556 | ): Promise => { 1557 | let data: GetRecommendedUsersPaginatedResponse | ErrorResponse | undefined = undefined; 1558 | try { 1559 | const res = await this._fetchAuthGetRequest( 1560 | `${BASE_API_URL}/api/v1/text_feed/recommended_users/?${maxID ? `?max_id=${maxID}` : ''}`, 1561 | options, 1562 | ); 1563 | data = res.data; 1564 | } catch (error: any) { 1565 | data = error.response?.data; 1566 | } 1567 | if (data?.status !== 'ok') { 1568 | if (this.verbose) { 1569 | console.log('[RECOMMENDED] Failed to fetch', data); 1570 | } 1571 | throw new ThreadsAPIError('Failed to fetch recommended users: ' + JSON.stringify(data), data); 1572 | } 1573 | return data; 1574 | }; 1575 | 1576 | getToken = async (): Promise => { 1577 | if (this.token) { 1578 | if (this.verbose) { 1579 | console.debug('[token] USING', this.token); 1580 | } 1581 | return this.token; 1582 | } 1583 | 1584 | if (!this.username || !this.password) { 1585 | throw new Error('Username and password are required'); 1586 | } 1587 | await this.login(); 1588 | return this.token; 1589 | }; 1590 | 1591 | _timezoneOffset: number | undefined; 1592 | _lastUploadID = 0; 1593 | _nextUploadID = () => { 1594 | const now = Date.now(); 1595 | const lastUploadID = this._lastUploadID; 1596 | // Avoid upload_id collisions. 1597 | return (this._lastUploadID = now < lastUploadID ? lastUploadID + 1 : now).toString(); 1598 | }; 1599 | _createUploadMetadata = (uploadID = this._nextUploadID()) => { 1600 | return { 1601 | upload_id: uploadID, 1602 | source_type: '4', 1603 | timezone_offset: (this._timezoneOffset ??= -(new Date().getTimezoneOffset() * 60)).toString(), 1604 | device: this.device, 1605 | }; 1606 | }; 1607 | 1608 | publish = async (rawOptions: ThreadsAPI.PublishOptions | string): Promise => { 1609 | const options: ThreadsAPI.PublishOptions = 1610 | typeof rawOptions === 'string' ? { text: rawOptions } : rawOptions; 1611 | 1612 | if (!this.token && (!this.username || !this.password)) { 1613 | throw new Error('Username or password not set'); 1614 | } 1615 | 1616 | const userID = await this.getCurrentUserID(); 1617 | if (!userID) { 1618 | throw new Error('User ID not found'); 1619 | } 1620 | 1621 | const token = await this.getToken(); 1622 | if (!token) { 1623 | throw new Error('Token not found'); 1624 | } 1625 | 1626 | let data: any = { 1627 | ...this._createUploadMetadata(), 1628 | text_post_app_info: { 1629 | reply_control: REPLY_CONTROL_OPTIONS[options.replyControl ?? 'everyone'], 1630 | }, 1631 | _uid: userID, 1632 | device_id: this.deviceID, 1633 | caption: options.text || '', 1634 | }; 1635 | 1636 | let endpoint = POST_URL; 1637 | let attachment = options.attachment; 1638 | if (!attachment) { 1639 | if ('image' in options && options.image) { 1640 | attachment = { image: options.image }; 1641 | } else if ('url' in options && options.url) { 1642 | attachment = { url: options.url }; 1643 | } 1644 | } 1645 | 1646 | if (attachment) { 1647 | if (attachment.url) { 1648 | data.text_post_app_info.link_attachment_url = attachment.url; 1649 | } else if (attachment.image) { 1650 | endpoint = POST_WITH_IMAGE_URL; 1651 | await this.uploadImage(attachment.image, data.upload_id); 1652 | data.scene_type = null; 1653 | data.scene_capture_type = ''; 1654 | } else if (attachment.sidecar) { 1655 | endpoint = POST_WITH_SIDECAR_URL; 1656 | data.client_sidecar_id = data.upload_id; 1657 | data.children_metadata = []; 1658 | for (const image of attachment.sidecar) { 1659 | // Images are uploaded one at a time, just like the app does. 1660 | const imageUploadID = (await this.uploadImage(image)).upload_id; 1661 | data.children_metadata.push({ 1662 | ...this._createUploadMetadata(imageUploadID), 1663 | scene_type: null, 1664 | scene_capture_type: '', 1665 | }); 1666 | } 1667 | } 1668 | } 1669 | 1670 | if (!!options.parentPostID) { 1671 | // Ensure no user ID is included in the parent post ID. 1672 | data.text_post_app_info.reply_id = options.parentPostID.replace(/_\d+$/, ''); 1673 | } 1674 | if (!!options.quotedPostID) { 1675 | // Ensure no user ID is included in the quoted post ID. 1676 | data.text_post_app_info.quoted_post_id = options.quotedPostID.replace(/_\d+$/, ''); 1677 | } 1678 | if (endpoint === POST_URL) { 1679 | data.publish_mode = 'text_post'; 1680 | } 1681 | 1682 | const payload = `signed_body=SIGNATURE.${encodeURIComponent(JSON.stringify(data))}`; 1683 | 1684 | type Response = { 1685 | media: { 1686 | id: string; 1687 | }; 1688 | status: 'ok'; 1689 | }; 1690 | 1691 | const res = await axios.post(endpoint, payload, { 1692 | httpAgent: this.httpAgent, 1693 | httpsAgent: this.httpsAgent, 1694 | headers: this._getAppHeaders(), 1695 | timeout: 60 * 1000, 1696 | }); 1697 | 1698 | if (this.verbose) { 1699 | console.debug('[PUBLISH]', res.data); 1700 | } 1701 | 1702 | if (res.data.status === 'ok') { 1703 | return res.data.media.id; 1704 | } 1705 | 1706 | return undefined; 1707 | }; 1708 | 1709 | delete = async (postID: string, options?: AxiosRequestConfig): Promise => { 1710 | const url = `${BASE_API_URL}/api/v1/media/${postID}/delete/`; 1711 | const data = { 1712 | media_id: postID, 1713 | _uid: this.userID, 1714 | _uuid: this.deviceID, 1715 | }; 1716 | const payload = `signed_body=SIGNATURE.${encodeURIComponent(JSON.stringify(data))}`; 1717 | 1718 | const res = await axios.post(url, payload, { 1719 | httpAgent: this.httpAgent, 1720 | httpsAgent: this.httpsAgent, 1721 | headers: this._getAppHeaders(), 1722 | timeout: 60 * 1000, 1723 | ...options, 1724 | }); 1725 | if (res.data.status === 'ok') { 1726 | return true; 1727 | } 1728 | return false; 1729 | }; 1730 | 1731 | /** 1732 | * @deprecated: use `publish` instead 1733 | **/ 1734 | publishWithImage = async (caption: string, imagePath: string): Promise => { 1735 | return this.publish({ text: caption, image: imagePath }); 1736 | }; 1737 | 1738 | uploadImage = async ( 1739 | image: ThreadsAPI.Image, 1740 | uploadID = this._nextUploadID(), 1741 | ): Promise => { 1742 | const name = `${uploadID}_0_${Math.floor(Math.random() * (9999999999 - 1000000000 + 1) + 1000000000)}`; 1743 | const url: string = `https://www.instagram.com/rupload_igphoto/${name}`; 1744 | 1745 | let content: Buffer; 1746 | let mime_type: string | null; 1747 | 1748 | if (typeof image === 'string' || 'path' in image) { 1749 | const imagePath = typeof image === 'string' ? image : image.path; 1750 | const isFilePath = !imagePath.startsWith('http'); 1751 | if (isFilePath) { 1752 | const fs = await import('fs'); 1753 | content = await fs.promises.readFile(imagePath); 1754 | const mimeTypeResult = mimeTypes.lookup(imagePath); 1755 | mime_type = mimeTypeResult ? mimeTypeResult : 'application/octet-stream'; 1756 | } else { 1757 | const response = await axios.get(imagePath, { responseType: 'arraybuffer' }); 1758 | content = Buffer.from(response.data, 'binary'); 1759 | mime_type = response.headers['content-type']; 1760 | } 1761 | } else { 1762 | content = image.data; 1763 | const mimeTypeResult = image.type.includes('/') ? image.type : mimeTypes.lookup(image.type); 1764 | mime_type = mimeTypeResult ? mimeTypeResult : 'application/octet-stream'; 1765 | } 1766 | 1767 | const x_instagram_rupload_params = { 1768 | upload_id: uploadID, 1769 | media_type: '1', 1770 | sticker_burnin_params: JSON.stringify([]), 1771 | image_compression: JSON.stringify({ lib_name: 'moz', lib_version: '3.1.m', quality: '80' }), 1772 | xsharing_user_ids: JSON.stringify([]), 1773 | retry_context: JSON.stringify({ 1774 | num_step_auto_retry: '0', 1775 | num_reupload: '0', 1776 | num_step_manual_retry: '0', 1777 | }), 1778 | 'IG-FB-Xpost-entry-point-v2': 'feed', 1779 | }; 1780 | 1781 | const contentLength = content.length; 1782 | const imageHeaders: any = { 1783 | ...this._getDefaultHeaders(), 1784 | 'Content-Type': 'application/octet-stream', 1785 | X_FB_PHOTO_WATERFALL_ID: uuidv4(), 1786 | 'X-Entity-Type': mime_type!! !== undefined ? `image/${mime_type!!}` : 'image/jpeg', 1787 | Offset: '0', 1788 | 'X-Instagram-Rupload-Params': JSON.stringify(x_instagram_rupload_params), 1789 | 'X-Entity-Name': name, 1790 | 'X-Entity-Length': contentLength.toString(), 1791 | 'Content-Length': contentLength.toString(), 1792 | 'Accept-Encoding': 'gzip', 1793 | }; 1794 | 1795 | if (this.verbose) { 1796 | console.log(`[UPLOAD_IMAGE] Uploading ${contentLength.toLocaleString()}b as ${uploadID}...`); 1797 | } 1798 | 1799 | try { 1800 | const { data } = await axios.post(url, content, { 1801 | httpAgent: this.httpAgent, 1802 | headers: imageHeaders, 1803 | timeout: 60 * 1000, 1804 | }); 1805 | if (this.verbose) { 1806 | console.log(`[UPLOAD_IMAGE] SUCCESS`, data); 1807 | } 1808 | return data; 1809 | } catch (error: any) { 1810 | if (this.verbose) { 1811 | console.log(`[UPLOAD_IMAGE] FAILED`, error.response.data); 1812 | } 1813 | throw new Error('Upload image failed'); 1814 | } 1815 | }; 1816 | } 1817 | 1818 | /** @deprecated Use `ThreadsAPI.Options` instead. */ 1819 | export type ThreadsAPIOptions = ThreadsAPI.Options; 1820 | 1821 | /** @deprecated Use `ThreadsAPI.PostReplyControl` instead. */ 1822 | export type ThreadsAPIPostReplyControl = ThreadsAPI.PostReplyControl; 1823 | 1824 | /** @deprecated Use `ThreadsAPI.RawImage` or `ThreadsAPI.ExternalImage` instead. */ 1825 | export type ThreadsAPIImage = ThreadsAPI.RawImage | ThreadsAPI.ExternalImage; 1826 | 1827 | /** @deprecated Use `ThreadsAPI.PublishOptions` instead. */ 1828 | export type ThreadsAPIPublishOptions = ThreadsAPI.PublishOptions; 1829 | 1830 | export default ThreadsAPI; 1831 | -------------------------------------------------------------------------------- /threads-api/src/threads-types.ts: -------------------------------------------------------------------------------- 1 | export interface ThreadsUser 2 | extends Partial<{ 3 | biography: string; 4 | biography_with_entities: ThreadsBiographyWithEntities; 5 | external_url: string; 6 | primary_profile_link_type: number; 7 | show_fb_link_on_profile: boolean; 8 | show_fb_page_link_on_profile: boolean; 9 | can_hide_category: boolean; 10 | category?: any; 11 | is_category_tappable: boolean; 12 | is_business: boolean; 13 | professional_conversion_suggested_account_type: number; 14 | account_type: number; 15 | displayed_action_button_partner?: any; 16 | smb_delivery_partner?: any; 17 | smb_support_delivery_partner?: any; 18 | displayed_action_button_type?: any; 19 | smb_support_partner?: any; 20 | is_call_to_action_enabled?: any; 21 | num_of_admined_pages?: any; 22 | page_id?: any; 23 | page_name?: any; 24 | ads_page_id?: any; 25 | ads_page_name?: any; 26 | account_badges?: any[]; 27 | fbid_v2: string; 28 | full_name: string; 29 | follower_count: number; 30 | following_count: number; 31 | following_tag_count: number; 32 | has_anonymous_profile_picture: boolean; 33 | has_onboarded_to_text_post_app: boolean; 34 | is_private: boolean; 35 | is_verified: boolean; 36 | media_count: number; 37 | pk: number; 38 | pk_id: string; 39 | profile_pic_id: string; 40 | text_post_app_joiner_number: number; 41 | third_party_downloads_enabled: number; 42 | username: string; 43 | current_catalog_id?: any; 44 | mini_shop_seller_onboarding_status?: any; 45 | shopping_post_onboard_nux_type?: any; 46 | ads_incentive_expiration_date?: any; 47 | account_category: string; 48 | auto_expand_chaining?: any; 49 | bio_interests: ThreadsBioInterests; 50 | bio_links: any[]; 51 | can_add_fb_group_link_on_profile: boolean; 52 | can_use_affiliate_partnership_messaging_as_creator: boolean; 53 | can_use_affiliate_partnership_messaging_as_brand: boolean; 54 | can_use_branded_content_discovery_as_brand: boolean; 55 | can_use_branded_content_discovery_as_creator: boolean; 56 | creator_shopping_info: ThreadsCreatorShoppingInfo; 57 | existing_user_age_collection_enabled: boolean; 58 | fan_club_info: ThreadsFanClubInfo; 59 | feed_post_reshare_disabled: boolean; 60 | follow_friction_type: number; 61 | has_chaining: boolean; 62 | has_collab_collections: boolean; 63 | has_exclusive_feed_content: boolean; 64 | has_fan_club_subscriptions: boolean; 65 | has_guides: boolean; 66 | has_highlight_reels: boolean; 67 | has_music_on_profile: boolean; 68 | has_private_collections: boolean; 69 | has_public_tab_threads: boolean; 70 | has_videos: boolean; 71 | hd_profile_pic_url_info: ThreadsHdProfilePicVersion; 72 | hd_profile_pic_versions: ThreadsHdProfilePicVersion[]; 73 | highlight_reshare_disabled: boolean; 74 | include_direct_blacklist_status: boolean; 75 | is_bestie: boolean; 76 | is_favorite: boolean; 77 | is_favorite_for_stories: boolean; 78 | is_favorite_for_igtv: boolean; 79 | is_favorite_for_clips: boolean; 80 | is_favorite_for_highlights: boolean; 81 | is_in_canada: boolean; 82 | is_interest_account: boolean; 83 | is_memorialized: boolean; 84 | is_new_to_instagram: boolean; 85 | is_potential_business: boolean; 86 | is_profile_broadcast_sharing_enabled: boolean; 87 | is_regulated_c18: boolean; 88 | is_supervision_features_enabled: boolean; 89 | is_whatsapp_linked: boolean; 90 | live_subscription_status: string; 91 | mutual_followers_count: number; 92 | nametag?: any; 93 | open_external_url_with_in_app_browser: boolean; 94 | pinned_channels_info: ThreadsPinnedChannelsInfo; 95 | profile_context: string; 96 | profile_context_facepile_users: any[]; 97 | profile_context_links_with_user_ids: any[]; 98 | profile_pic_url: string; 99 | profile_type: number; 100 | pronouns: any[]; 101 | remove_message_entrypoint: boolean; 102 | robi_feedback_source?: any; 103 | show_account_transparency_details: boolean; 104 | show_ig_app_switcher_badge: boolean; 105 | show_post_insights_entry_point: boolean; 106 | show_text_post_app_badge: boolean; 107 | show_text_post_app_switcher_badge: boolean; 108 | total_ar_effects: number; 109 | total_igtv_videos: number; 110 | transparency_product_enabled: boolean; 111 | usertags_count: number; 112 | id: any; 113 | }> { 114 | pk: number; 115 | username: string; 116 | full_name: string; 117 | is_verified: boolean; 118 | is_private: boolean; 119 | profile_pic_url: string; 120 | } 121 | 122 | export interface ThreadsBiographyWithEntities { 123 | raw_text: string; 124 | entities?: null[] | null; 125 | } 126 | export interface ThreadsBioInterests { 127 | interests?: null[] | null; 128 | } 129 | export interface ThreadsCreatorShoppingInfo { 130 | linked_merchant_accounts?: null[] | null; 131 | } 132 | export interface ThreadsFanClubInfo { 133 | fan_club_id?: any; 134 | fan_club_name?: any; 135 | is_fan_club_referral_eligible?: any; 136 | fan_consideration_page_revamp_eligiblity?: any; 137 | is_fan_club_gifting_eligible?: any; 138 | subscriber_count?: any; 139 | connected_member_count?: any; 140 | autosave_to_exclusive_highlight?: any; 141 | has_enough_subscribers_for_ssc?: any; 142 | } 143 | 144 | export interface ThreadsPinnedChannelsInfo { 145 | pinned_channels_list?: null[] | null; 146 | has_public_channels: boolean; 147 | } 148 | export interface ThreadsHdProfilePicVersion { 149 | url: string; 150 | width: number; 151 | height: number; 152 | } 153 | export interface ThreadsBioLink { 154 | url: string; 155 | } 156 | 157 | // export interface ThreadsUser { 158 | // is_private: boolean; 159 | // profile_pic_url: string; 160 | // username: string; 161 | // hd_profile_pic_versions: ThreadsHdProfilePicVersion[]; 162 | // is_verified: boolean; 163 | // biography: string; 164 | // biography_with_entities: any; 165 | // follower_count: number; 166 | // profile_context_facepile_users: any; 167 | // bio_links: ThreadsBioLink[]; 168 | // pk: string; 169 | // full_name: string; 170 | // id: any; 171 | // } 172 | // export interface ThreadsHdProfilePicVersion { 173 | // height: number; 174 | // url: string; 175 | // width: number; 176 | // } 177 | // export interface ThreadsBioLink { 178 | // url: string; 179 | // } 180 | 181 | export interface Thread { 182 | thread_items: ThreadItem[]; 183 | header?: any; 184 | thread_type: string; 185 | show_create_reply_cta: boolean; 186 | id: string; 187 | view_state_item_type?: number; 188 | posts: Post[]; 189 | } 190 | 191 | export interface ThreadItem { 192 | post: Post; 193 | line_type: string; 194 | view_replies_cta_string?: string; 195 | should_show_replies_cta: boolean; 196 | reply_facepile_users: ReplyFacepileUser[]; 197 | reply_to_author?: any; 198 | can_inline_expand_below: boolean; 199 | __typename: string; 200 | } 201 | 202 | export interface TextPostAppInfo { 203 | is_post_unavailable: boolean; 204 | is_reply: boolean; 205 | reply_to_author: boolean; 206 | direct_reply_count: number; 207 | self_thread: boolean; 208 | reply_facepile_users: ReplyFacepileUser[]; 209 | link_preview_attachment: LinkPreviewAttachment; 210 | can_reply: boolean; 211 | reply_control: string; 212 | hush_info: any; 213 | share_info: ShareInfo; 214 | } 215 | 216 | export interface LinkPreviewAttachment { 217 | url: string; 218 | display_url: string; 219 | title: string; 220 | image_url: string; 221 | } 222 | 223 | export interface Post { 224 | pk: string; 225 | id: string; 226 | text_post_app_info: TextPostAppInfo; 227 | caption?: Caption | null; 228 | taken_at: number; 229 | device_timestamp: number; 230 | media_type: number; 231 | code: string; 232 | client_cache_key: string; 233 | filter_type: number; 234 | product_type: string; 235 | organic_tracking_token: string; 236 | image_versions2: ImageVersions2; 237 | original_width: number; 238 | original_height: number; 239 | video_versions: any[]; 240 | like_count: number; 241 | timezone_offset: number; 242 | has_liked: boolean; 243 | like_and_view_counts_disabled: boolean; 244 | can_viewer_reshare: boolean; 245 | integrity_review_decision: string; 246 | top_likers: any[]; 247 | user: ThreadsUser; 248 | carousel_media_count?: any; 249 | carousel_media?: any; 250 | carousel_media_ids?: string[]; 251 | has_audio?: any; 252 | media_overlay_info: any; 253 | } 254 | 255 | export interface FriendshipStatus { 256 | following: boolean; 257 | followed_by: boolean; 258 | blocking: boolean; 259 | muting: boolean; 260 | is_private: boolean; 261 | incoming_request: boolean; 262 | outgoing_request: boolean; 263 | text_post_app_pre_following: boolean; 264 | is_bestie: boolean; 265 | is_restricted: boolean; 266 | is_feed_favorite: boolean; 267 | is_eligible_to_subscribe: boolean; 268 | } 269 | 270 | export interface ImageVersions2 { 271 | candidates: Candidate[] | ThreadsHdProfilePicVersion[]; 272 | } 273 | 274 | export interface Candidate { 275 | width: number; 276 | height: number; 277 | url: string; 278 | scans_profile: string; 279 | __typename: string; 280 | } 281 | 282 | export interface ShareInfo { 283 | can_repost: boolean; 284 | is_reposted_by_viewer: boolean; 285 | repost_restricted_reason?: any; 286 | can_quote_post: boolean; 287 | quoted_post?: Post | null; 288 | reposted_post?: Post | null; 289 | } 290 | 291 | export interface VideoVersion { 292 | type: number; 293 | width: number; 294 | height: number; 295 | url: string; 296 | id: string; 297 | __typename: string; 298 | } 299 | 300 | export interface Caption { 301 | pk: string; 302 | user_id: any; 303 | text: string; 304 | type: number; 305 | created_at: number; 306 | created_at_utc: number; 307 | content_type: string; 308 | status: string; 309 | bit_flags: number; 310 | did_report_as_spam: boolean; 311 | share_enabled: boolean; 312 | user: ThreadsUser; 313 | is_covered: boolean; 314 | is_ranked_comment: boolean; 315 | media_id: any; 316 | private_reply_status: number; 317 | } 318 | 319 | export interface ReplyFacepileUser { 320 | __typename: string; 321 | id: any; 322 | profile_pic_url: string; 323 | } 324 | 325 | export interface Extensions { 326 | is_final: boolean; 327 | } 328 | 329 | export interface Story { 330 | story_type: number; 331 | type: number; 332 | args: StoryArgs; 333 | counts: any[]; 334 | pk: string; 335 | } 336 | 337 | export interface StoryArgs { 338 | extra_actions?: string[] | null; 339 | profile_id: number; 340 | profile_name: string; 341 | profile_image: string; 342 | profile_image_destination: string; 343 | destination: string; 344 | rich_text: string; 345 | extra: StoryExtra; 346 | actions?: string[] | null; 347 | inline_controls?: StoryInlineControls[] | null; 348 | timestamp: number; 349 | tuuid: string; 350 | clicked: boolean; 351 | af_candidate_id: number; 352 | } 353 | 354 | export interface StoryExtra { 355 | title: string; 356 | is_aggregated: boolean; 357 | icon_name: string; 358 | icon_color: string; 359 | icon_url: string; 360 | context: string; 361 | content: string; 362 | } 363 | 364 | export interface StoryInlineControls { 365 | action_type: string; 366 | } 367 | 368 | export interface AndroidDevice { 369 | manufacturer: string; 370 | model: string; 371 | os_version: number; 372 | os_release: string; 373 | } 374 | -------------------------------------------------------------------------------- /threads-api/src/types/utils.ts: -------------------------------------------------------------------------------- 1 | // This type simplifies a complex type into an object literal for better readability. 2 | export type Simplify = {} & { [P in keyof T]: T[P] }; 3 | 4 | // This takes a union of object types and ensures all of them contain the same properties. This 5 | // helps in strict enforcement of the following rule: The object provided by the user cannot be 6 | // assignable to more than one of the allowed object types. 7 | export type StrictUnion = CombineUnion extends infer U 8 | ? T extends any 9 | ? Simplify]?: undefined }> 10 | : never 11 | : never; 12 | 13 | // This turns a union of object types into a single object type. Any property that doesn't exist in 14 | // all of the objects will be optional. Any property that exists in all of the objects will have its 15 | // optionality preserved. 16 | export type CombineUnion = Pick & 17 | Partial<(T extends any ? (x: T) => any : never) extends (x: infer U) => any ? U : never>; 18 | -------------------------------------------------------------------------------- /threads-api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "lib": ["esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "declaration": true, 10 | "module": "esnext", 11 | "moduleResolution": "node", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "baseUrl": "src", 15 | "outDir": "build" 16 | }, 17 | "include": ["src/**/*.ts"], 18 | "exclude": ["node_modules"] 19 | } 20 | -------------------------------------------------------------------------------- /threads-web-ui/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | 36 | -------------------------------------------------------------------------------- /threads-web-ui/app/[username]/page.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import Link from 'next/link'; 3 | import { redirect } from 'next/navigation'; 4 | import React from 'react'; 5 | import { Thread, ThreadsIcons } from 'react-threads'; 6 | import { Thread as ThreadType, ThreadsUser } from 'threads-api'; 7 | 8 | import { threadsAPI } from '@/lib/api'; 9 | 10 | const UserProfilePage = async ({ params }: { params: { username: string } }) => { 11 | if (!params.username.startsWith('%40') && !params.username.startsWith('@')) { 12 | redirect(`/@${params.username}`); 13 | } 14 | 15 | const username = params.username.replaceAll('%40', '').replaceAll('@', ''); 16 | if (!username) { 17 | return; 18 | } 19 | 20 | const userID = await threadsAPI.getUserIDfromUsername(username); 21 | if (!userID) { 22 | return
No user with the given username
; 23 | } 24 | 25 | let userProfile: ThreadsUser; 26 | let userThreads: ThreadType[]; 27 | try { 28 | [userProfile, userThreads] = await Promise.all([ 29 | threadsAPI.getUserProfile(userID), 30 | threadsAPI.getUserProfileThreads(userID), 31 | ]); 32 | } catch (e) { 33 | console.log(e); 34 | return
Failed to fetch Threads account
; 35 | } 36 | 37 | return ( 38 |
39 |
40 |
41 |
42 |
43 | {userProfile.full_name} 44 | {userProfile.is_verified && } 45 |
46 |
47 | {userProfile.username} 48 | 49 | threads.net 50 | 51 |
52 |
53 | 54 | 61 |
62 | 63 |

{userProfile.biography}

64 | 65 |
66 | 67 | {!userProfile.follower_count ? '-' : userProfile.follower_count.toLocaleString()} followers 68 | 69 |
70 |
71 | 72 |
73 | {userThreads.map((thread, index) => { 74 | return ( 75 | 76 |
77 | 78 |
79 | {index !== userThreads.length - 1 && ( 80 |
81 | )} 82 | 83 | ); 84 | })} 85 |
86 |
87 | ); 88 | }; 89 | 90 | export default UserProfilePage; 91 | -------------------------------------------------------------------------------- /threads-web-ui/app/apps/page.tsx: -------------------------------------------------------------------------------- 1 | import { AnalyticsTrackerLogView } from '@/components/AnalyticsTracker'; 2 | import { AppRegistryItem } from '@/components/AppRegistryItem'; 3 | 4 | import { APPS } from '@/data/apps'; 5 | 6 | export default async function AppDirectory() { 7 | return ( 8 | <> 9 | 10 |
11 |
12 |

Explore Apps

13 |

14 | Community-Made Apps That Fuels the Text Metaverse. 15 |

16 |
17 | 18 |
    19 | {APPS.map((app) => ( 20 | 21 | ))} 22 |
23 |
24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /threads-web-ui/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | 10 | --muted: 210 40% 96.1%; 11 | --muted-foreground: 215.4 16.3% 46.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 84% 4.9%; 15 | 16 | --card: 0 0% 100%; 17 | --card-foreground: 222.2 84% 4.9%; 18 | 19 | --border: 214.3 31.8% 91.4%; 20 | --input: 214.3 31.8% 91.4%; 21 | 22 | --primary: 222.2 47.4% 11.2%; 23 | --primary-foreground: 210 40% 98%; 24 | 25 | --secondary: 210 40% 96.1%; 26 | --secondary-foreground: 222.2 47.4% 11.2%; 27 | 28 | --accent: 210 40% 96.1%; 29 | --accent-foreground: 222.2 47.4% 11.2%; 30 | 31 | --destructive: 0 84.2% 60.2%; 32 | --destructive-foreground: 210 40% 98%; 33 | 34 | --ring: 215 20.2% 65.1%; 35 | 36 | --radius: 0.5rem; 37 | } 38 | 39 | .dark { 40 | --background: 222.2 84% 4.9%; 41 | --foreground: 210 40% 98%; 42 | 43 | --muted: 217.2 32.6% 17.5%; 44 | --muted-foreground: 215 20.2% 65.1%; 45 | 46 | --popover: 222.2 84% 4.9%; 47 | --popover-foreground: 210 40% 98%; 48 | 49 | --card: 222.2 84% 4.9%; 50 | --card-foreground: 210 40% 98%; 51 | 52 | --border: 217.2 32.6% 17.5%; 53 | --input: 217.2 32.6% 17.5%; 54 | 55 | --primary: 210 40% 98%; 56 | --primary-foreground: 222.2 47.4% 11.2%; 57 | 58 | --secondary: 217.2 32.6% 17.5%; 59 | --secondary-foreground: 210 40% 98%; 60 | 61 | --accent: 217.2 32.6% 17.5%; 62 | --accent-foreground: 210 40% 98%; 63 | 64 | --destructive: 0 62.8% 30.6%; 65 | --destructive-foreground: 0 85.7% 97.3%; 66 | 67 | --ring: 217.2 32.6% 17.5%; 68 | } 69 | } 70 | 71 | @layer base { 72 | * { 73 | @apply border-border; 74 | } 75 | body { 76 | @apply bg-background text-foreground; 77 | } 78 | } 79 | 80 | * { 81 | box-sizing: border-box; 82 | word-break: keep-all; 83 | } 84 | 85 | a { 86 | text-decoration: none; 87 | cursor: pointer; 88 | } 89 | 90 | input, 91 | button { 92 | outline: 0; 93 | background-color: transparent; 94 | } 95 | 96 | button { 97 | cursor: pointer; 98 | } 99 | 100 | html, 101 | body { 102 | background-color: #101010; 103 | } 104 | 105 | body { 106 | color: slategray; 107 | } 108 | 109 | img { 110 | user-select: none; 111 | -webkit-user-drag: none; 112 | } 113 | 114 | ::selection { 115 | background-color: #e5e7eb; 116 | color: #111827; 117 | } 118 | 119 | img.twemoji { 120 | height: 1em; 121 | width: auto; 122 | display: inline-block; 123 | } 124 | -------------------------------------------------------------------------------- /threads-web-ui/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | import { Inter } from 'next/font/google'; 3 | 4 | import { AnalyticsTrackerInit } from '@/components/AnalyticsTracker'; 5 | import { Footer } from '@/components/Footer'; 6 | import { NavigationBar } from '@/components/NavigationBar'; 7 | 8 | import './globals.css'; 9 | 10 | const inter = Inter({ subsets: ['latin'] }); 11 | 12 | export const metadata: Metadata = { 13 | title: 'Threads API: Making Threads Work in Code', 14 | description: 'Unofficial, Reverse-Engineered Clients for Threads.', 15 | }; 16 | 17 | export default function Layout({ children }: { children: React.ReactNode }) { 18 | return ( 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | {children} 31 | 32 |