├── .github ├── logo.jpg ├── user_example1.jpeg ├── user_example2.jpg └── workflows │ └── python-tests.yml ├── .gitignore ├── LICENSE ├── README.md ├── example.py ├── examples ├── private_api_examples.py └── public_api_examples.py ├── poetry.lock ├── pyproject.toml ├── requirements.txt ├── setup.py └── threads_api ├── __init__.py ├── src ├── __init__.py ├── anotherlogger.py ├── http_sessions │ ├── __init__.py │ ├── abstract_session.py │ ├── aiohttp_session.py │ ├── instagrapi_session.py │ └── requests_session.py ├── settings.py ├── threads_api.py └── types.py └── tests ├── __init__.py └── ut ├── __init__.py └── get_post_test.py /.github/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Danie1/threads-api/00ce459eff46c6109928b9ef9734eba91f499fd0/.github/logo.jpg -------------------------------------------------------------------------------- /.github/user_example1.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Danie1/threads-api/00ce459eff46c6109928b9ef9734eba91f499fd0/.github/user_example1.jpeg -------------------------------------------------------------------------------- /.github/user_example2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Danie1/threads-api/00ce459eff46c6109928b9ef9734eba91f499fd0/.github/user_example2.jpg -------------------------------------------------------------------------------- /.github/workflows/python-tests.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: Threads-API Main Workflow 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | python-version: ["3.8", "3.9", "3.10", "3.11"] 20 | poetry-version: ["1.1.15"] 21 | os: [ubuntu-18.04, macos-latest, windows-latest] 22 | steps: 23 | - uses: actions/checkout@v3 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v4 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | - name: Run image 29 | uses: abatilo/actions-poetry@v2 30 | with: 31 | poetry-version: ${{ matrix.poetry-version }} 32 | - name: Install poetry dependencies 33 | run: poetry install 34 | - name: Install flake8 with poetry 35 | run: poetry add flake8 36 | - name: Lint with flake8 37 | run: | 38 | # stop the build if there are Python syntax errors or undefined names 39 | python -m poetry run python -m flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 40 | - name: Run tests 41 | run: | 42 | python -m poetry run python -m pytest -sxv --disable-pytest-warnings 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/__pycache__ 2 | **/.unotes 3 | **/.pytest_cache 4 | dist 5 | build 6 | *.egg-info 7 | .token 8 | .session.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Daniel Saad 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > As of October 1st 2023 this repository is archived, and no longer be maintained. 2 | --- 3 | 4 | # [](https://github.com/danie1) Meta's Threads.net API 5 | 6 | 7 | 8 | [![Downdloads](https://pepy.tech/badge/threads-api)](https://pypi.org/project/threads-api/) 9 | [![Version](https://img.shields.io/pypi/v/threads-api.svg?style=flat)](https://pypi.org/project/threads-api/) 10 | [![Releases](https://img.shields.io/github/v/release/danie1/threads-api.svg)](https://github.com/danie1/threads-api/releases) 11 | [![Python](https://img.shields.io/pypi/pyversions/threads-api.svg)](https://pypi.org/project/threads-api/) [![MIT License](https://img.shields.io/pypi/l/threads-api.svg?style=flat)](https://github.com/Danie1/threads-api/blob/main/LICENSE) 12 | 13 | [![Workflow Status](https://github.com/Danie1/threads-api/actions/workflows/python-tests.yml/badge.svg?branch=main)](https://github.com/Danie1/threads-api/actions/workflows/python-tests.yml) 14 | 15 | 16 | 17 | > Unofficial, Reverse-Engineered Python client for Meta's [Threads](https://threads.net). 18 | 19 | Inspired by [NPM Threads-API](https://github.com/junhoyeo/threads-api) 20 | 21 | # Threads API - Python 22 | 23 | Threads API is an unofficial Python client for Meta's Threads API. It allows you to interact with the API to login, read and publish posts, view who liked a post, retrieve user profile information, follow/unfollow and much more. 24 | 25 | ✅ Configurable underlying HTTP Client (`aiohttp` / `requests` / `instagrapi`'s client / implement your own) 26 | 27 | ✅ Authentication Methods supported via `instagrapi` 28 | 29 | ✅ Stores token and settings locally, to reduce login-attempts (*uses the same token for all authenticated requests for up to 24 hrs*) 30 | 31 | ✅ Pydantic structures for API responses (for IDE auto-completion) (at [types.py](https://github.com/Danie1/threads-api/blob/main/threads_api/src/types.py)) 32 | 33 | ✅ Actively Maintained since Threads.net Release (responsive in Github Issues, try it out!) 34 | 35 | 36 | 37 | > **Important Tip** Use the same `cached_token_path` for connections, to reduce the number of actual login attempts. When needed, threads-api will reconnect and update the file in `cached_token_path`. 38 | 39 | Table of content: 40 | 41 | * [Demo](#demo) 42 | * [Getting started](#getting-started) 43 | * [Installation](#installation) 44 | * [Set Log Level & Troubleshooting](#set-desired-log-level) 45 | * [Set HTTP Client](#choose-a-different-http-client) 46 | * [Supported Features](#supported-features) 47 | * [Usage Examples](#usage-examples) 48 | * [Roadmap](#📌-roadmap) 49 | * [Contributions](#contributing-to-danie1threads-api) 50 | * [License](#license) 51 | 52 | # Demo 53 | drawing 54 | 55 | ## Getting Started 56 | 57 | ### 📦 Installation 58 | ```bash 59 | pip install threads-api 60 | ``` 61 | or 62 | ```bash 63 | poetry add threads-api 64 | ``` 65 | 66 | Example using threads-api to post to Threads.net: 67 | ``` python 68 | from threads_api.src.threads_api import ThreadsAPI 69 | import asyncio 70 | import os 71 | from dotenv import load_dotenv 72 | 73 | load_dotenv() 74 | 75 | async def post(): 76 | api = ThreadsAPI() 77 | 78 | await api.login(os.environ.get('INSTAGRAM_USERNAME'), os.environ.get('INSTAGRAM_PASSWORD'), cached_token_path=".token") 79 | result = await api.post(caption="Posting this from the Danie1/threads-api!", image_path=".github/logo.jpg") 80 | 81 | 82 | if result: 83 | print("Post has been successfully posted") 84 | else: 85 | print("Unable to post.") 86 | 87 | await api.close_gracefully() 88 | 89 | 90 | async def main(): 91 | await post() 92 | 93 | # Run the main function 94 | asyncio.run(main()) 95 | ``` 96 | 97 | ## Customize HTTP Client 98 | Each HTTP client brings to the table different functionality. Use whichever you like, or implement your own wrapper. 99 | 100 | Usage: 101 | ``` python 102 | api = ThreadsAPI(http_session_class=AioHTTPSession) # default 103 | # or 104 | api = ThreadsAPI(http_session_class=RequestsSession) 105 | # or 106 | api = ThreadsAPI(http_session_class=InstagrapiSession) 107 | ``` 108 | 109 | ## Set Desired Log Level 110 | Threads-API reads the environment variable ```LOG_LEVEL``` and sets the log-level according to its value. 111 | 112 | Possible values include: ```DEBUG, INFO, WARNING, ERROR, CRITICAL``` 113 | 114 | **Log Level defaults to WARNING when not set.** 115 | 116 | Useful to know: 117 | ``` bash 118 | # Set Info (Prints general flow) 119 | export LOG_LEVEL=INFO 120 | ``` 121 | 122 | ``` bash 123 | # Set Debug (Prints HTTP Requests + HTTP Responses) 124 | export LOG_LEVEL=DEBUG 125 | ``` 126 | 127 |
128 | Example of Request when LOG_LEVEL=DEBUG 129 | 130 | ``` bash 131 | <---- START ----> 132 | Keyword arguments: 133 | [title]: ["PUBLIC REQUEST"] 134 | [type]: ["GET"] 135 | [url]: ["https://www.instagram.com/instagram"] 136 | [headers]: [{ 137 | "Authority": "www.threads.net", 138 | "Accept": "*/*", 139 | "Accept-Language": "en-US,en;q=0.9", 140 | "Cache-Control": "no-cache", 141 | "Content-Type": "application/x-www-form-urlencoded", 142 | "Origin": "https://www.threads.net", 143 | "Pragma": "no-cache", 144 | "Sec-Fetch-Dest": "document", 145 | "Sec-Fetch-Mode": "navigate", 146 | "Sec-Fetch-Site": "cross-site", 147 | "Sec-Fetch-User": "?1", 148 | "Upgrade-Insecure-Requests": "1", 149 | "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.1.2 Safari/605.1.15", 150 | "X-ASBD-ID": "129477", 151 | "X-IG-App-ID": "238260118697367" 152 | }] 153 | <---- END ----> 154 | 155 | ``` 156 | 157 |
158 | 159 | ### Troubleshooting threads-api 160 | Upon unexpected error, or upon receiving an exception with: `Oops, this is an error that hasn't yet been properly handled.\nPlease open an issue on Github at https://github.com/Danie1/threads-api.` 161 | 162 | Please open a Github Issue with all of the information you can provide, which includes the last Request and Response (Set `LOG_LEVEL=DEBUG`) 163 | 164 | # Supported Features 165 | - [x] ✅ Pydantic typing API responses at [types.py](https://github.com/Danie1/threads-api/blob/main/threads_api/src/types.py) 166 | - [x] ✅ Login functionality, including 2FA 🔒 167 | - [x] ✅ Cache login token securely (reduce login requests / due to restrictive limits) 168 | - [x] ✅ Saves settings locally, such as device information and timezone to use along your sessions 169 | - [x] ✅ Read recommended posts from timeline (Requires Login 🔒) 170 | - [x] ✅ Write Posts (Requires Login 🔒) 171 | - [x] ✅ Posts with just text 172 | - [x] ✅ Posts and quote another post 173 | - [x] ✅ Posts with text and an image 174 | - [x] ✅ Posts with text and multiple images 175 | - [x] ✅ Posts with text that shares a url 176 | - [x] ✅ Repost a post 177 | - [x] ✅ Reply to Posts 178 | - [x] ✅ Perform Actions (Requires Login 🔒) 179 | - [x] ✅ Like Posts 180 | - [x] ✅ Unlike Posts 181 | - [x] ✅ Delete post 182 | - [x] ✅ Delete repost 183 | - [x] ✅ Follow User 184 | - [x] ✅ Unfollow User 185 | - [x] ✅ Block User 186 | - [x] ✅ Unblock User 187 | - [x] ✅ Restrict User 188 | - [x] ✅ Unrestrict User 189 | - [x] ✅ Mute User 190 | - [x] ✅ Unmute User 191 | - [x] ✅ Search for users 192 | - [x] ✅ Get Recommended Users 193 | - [x] ✅ Get Notifications (`replies` / `mentions` / `verified`) 194 | - [x] ✅ Read a user's followers list 195 | - [x] ✅ Read a user's following list 196 | - [x] ✅ Read Public Data 197 | - [x] ✅ Read a user_id (eg. `314216`) via username(eg. `zuck`) 198 | - [x] ✅ Read a user's profile info 199 | - [x] ✅ Read list of a user's Threads 200 | - [x] ✅ Read list of a user's Replies 201 | - [x] ✅ Read Post and a list of its Replies 202 | - [x] ✅ View who liked a post 203 | - [x] ✅ CI/CD 204 | - [x] ✅ GitHub Actions Pipeline 205 | - [x] ✅ HTTP Clients 206 | - [x] ✅ AioHTTP 207 | - [x] ✅ Requests 208 | - [x] ✅ Instagrapi 209 | 210 | ## Usage Examples 211 | View [examples/public_api_examples.py](https://github.com/Danie1/threads-api/blob/main/examples/public_api_examples.py) for Public API code examples. 212 | 213 | Run as: 214 | ``` bash 215 | python3 examples/public_api_examples.py 216 | ``` 217 | 218 | View [examples/private_api_examples.py](https://github.com/Danie1/threads-api/blob/main/examples/private_api_examples.py) for Private API code examples. (🔒 Requires Authentication 🔒) 219 | 220 | Run as: 221 | ``` 222 | USERNAME= PASSWORD= python3 examples/private_api_examples.py 223 | ``` 224 | 225 | > **Note:** 226 | > At the end of the file you will be able to uncomment and run the individual examples with ease. 227 | 228 | ## 📌 Roadmap 229 | - [ ] 🚧 Share a video 230 | - [ ] 🚧 Documentation Improvements 231 | - [ ] 🚧 Add coverage Pytest + Coverage Widget to README 232 | 233 | 234 | # Contributing to Danie1/threads-api 235 | ## Getting Started 236 | 237 | With Poetry (*Recommended*) 238 | ``` bash 239 | # Step 1: Clone the project 240 | git clone git@github.com:Danie1/threads-api.git 241 | 242 | # Step 2: Install dependencies to virtual environment 243 | poetry install 244 | 245 | # Step 3: Activate virtual environment 246 | poetry shell 247 | ``` 248 | or 249 | 250 | Without Poetry 251 | 252 | ``` bash 253 | # Step 1: Clone the project 254 | git clone git@github.com:Danie1/threads-api.git 255 | 256 | # Step 2: Create virtual environment 257 | python3 -m venv env 258 | 259 | # Step 3 (Unix/MacOS): Activate virtual environment 260 | source env/bin/activate # Unix/MacOS 261 | 262 | # Step 3 (Windows): Activate virtual environment 263 | .\env\Scripts\activate # Windows 264 | 265 | # Step 4: Install dependencies 266 | pip install -r requirements.txt 267 | ``` 268 | 269 | # License 270 | This project is licensed under the MIT license. 271 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | from threads_api.src.threads_api import ThreadsAPI 2 | import asyncio 3 | import os 4 | import logging 5 | from threads_api.src.http_sessions.instagrapi_session import InstagrapiSession 6 | from threads_api.src.http_sessions.requests_session import RequestsSession 7 | from threads_api.src.http_sessions.aiohttp_session import AioHTTPSession 8 | 9 | # Asynchronously gets the user ID from a username 10 | async def get_user_id_from_username(): 11 | api = ThreadsAPI() 12 | 13 | username = "zuck" 14 | user_id = await api.get_user_id_from_username(username) 15 | 16 | if user_id: 17 | print(f"The user ID for username '{username}' is: {user_id}") 18 | else: 19 | print(f"User ID not found for username '{username}'") 20 | 21 | await api.close_gracefully() 22 | 23 | # Asynchronously gets the threads for a user 24 | async def get_user_threads(): 25 | api = ThreadsAPI() 26 | 27 | username = "zuck" 28 | user_id = await api.get_user_id_from_username(username) 29 | 30 | if user_id: 31 | threads = await api.get_user_threads(user_id) 32 | print(f"The threads for user '{username}' are:") 33 | for thread in threads: 34 | print(f"{username}'s Post: {thread['thread_items'][0]['post']['caption']} || Likes: {thread['thread_items'][0]['post']['like_count']}") 35 | else: 36 | print(f"User ID not found for username '{username}'") 37 | 38 | await api.close_gracefully() 39 | 40 | # Asynchronously gets the replies for a user 41 | async def get_user_replies(): 42 | api = ThreadsAPI() 43 | 44 | username = "zuck" 45 | user_id = await api.get_user_id_from_username(username) 46 | 47 | if user_id: 48 | threads = await api.get_user_replies(user_id) 49 | print(f"The replies for user '{username}' are:") 50 | for thread in threads: 51 | print(f"-\n{thread['thread_items'][0]['post']['user']['username']}'s Post: {thread['thread_items'][0]['post']['caption']} || Likes: {thread['thread_items'][0]['post']['like_count']}") 52 | 53 | if len(thread["thread_items"]) > 1: 54 | print(f"{username}'s Reply: {thread['thread_items'][1]['post']['caption']} || Likes: {thread['thread_items'][1]['post']['like_count']}\n-") 55 | else: 56 | print(f"-> You will need to sign up / login to see more.") 57 | 58 | else: 59 | print(f"User ID not found for username '{username}'") 60 | 61 | await api.close_gracefully() 62 | 63 | 64 | # Asynchronously gets the user profile 65 | async def get_user_profile(): 66 | api = ThreadsAPI() 67 | 68 | username = "zuck" 69 | user_id = await api.get_user_id_from_username(username) 70 | 71 | if user_id: 72 | user_profile = await api.get_user_profile(user_id) 73 | print(f"User profile for '{username}':") 74 | print(f"Name: {user_profile['username']}") 75 | print(f"Bio: {user_profile['biography']}") 76 | print(f"Followers: {user_profile['follower_count']}") 77 | else: 78 | print(f"User ID not found for username '{username}'") 79 | 80 | await api.close_gracefully() 81 | 82 | # Asynchronously gets the post ID from a URL 83 | async def get_post_id_from_url(): 84 | api = ThreadsAPI() 85 | post_url = "https://www.threads.net/t/CuZsgfWLyiI" 86 | 87 | post_id = await api.get_post_id_from_url(post_url) 88 | print(f"Thread post_id is {post_id}") 89 | 90 | await api.close_gracefully() 91 | 92 | # Asynchronously gets a post 93 | async def get_post(): 94 | api = ThreadsAPI() 95 | post_url = "https://www.threads.net/t/CuZsgfWLyiI" 96 | 97 | post_id = await api.get_post_id_from_url(post_url) 98 | 99 | thread = await api.get_post(post_id) 100 | print(f"{thread['containing_thread']['thread_items'][0]['post']['user']['username']}'s post {thread['containing_thread']['thread_items'][0]['post']['caption']}:") 101 | 102 | for thread in thread["reply_threads"]: 103 | print(f"-\n{thread['thread_items'][0]['post']['user']['username']}'s Reply: {thread['thread_items'][0]['post']['caption']} || Likes: {thread['thread_items'][0]['post']['like_count']}") 104 | 105 | await api.close_gracefully() 106 | 107 | # Asynchronously gets the likes for a post 108 | async def get_post_likes(): 109 | api = ThreadsAPI() 110 | post_url = "https://www.threads.net/t/CuZsgfWLyiI" 111 | 112 | post_id = await api.get_post_id_from_url(post_url) 113 | 114 | likes = await api.get_post_likes(post_id) 115 | number_of_likes_to_display = 10 116 | 117 | for user_info in likes[:number_of_likes_to_display]: 118 | print(f'Username: {user_info["username"]} || Full Name: {user_info["full_name"]} || Follower Count: {user_info["follower_count"]} ') 119 | 120 | await api.close_gracefully() 121 | 122 | # Asynchronously posts a message 123 | async def post(): 124 | api = ThreadsAPI() 125 | is_success = await api.login(os.environ.get('USERNAME'), os.environ.get('PASSWORD'), cached_token_path=".token") 126 | print(f"Login status: {'Success' if is_success else 'Failed'}") 127 | 128 | if is_success: 129 | result = await api.post("Hello World!") 130 | 131 | if result: 132 | print("Post has been successfully posted") 133 | else: 134 | print("Unable to post.") 135 | 136 | await api.close_gracefully() 137 | 138 | # Asynchronously posts a message with an image 139 | async def post_include_image(): 140 | api = ThreadsAPI() 141 | is_success = await api.login(os.environ.get('USERNAME'), os.environ.get('PASSWORD'), cached_token_path=".token") 142 | print(f"Login status: {'Success' if is_success else 'Failed'}") 143 | 144 | result = False 145 | if is_success: 146 | result = await api.post("Hello World with an image!", image_path=".github/logo.jpg") 147 | 148 | if result: 149 | print("Post has been successfully posted") 150 | else: 151 | print("Unable to post.") 152 | 153 | await api.close_gracefully() 154 | 155 | # Asynchronously posts a message with an image 156 | async def post_include_image_from_url(): 157 | api = ThreadsAPI() 158 | is_success = await api.login(os.environ.get('USERNAME'), os.environ.get('PASSWORD'), cached_token_path=".token") 159 | print(f"Login status: {'Success' if is_success else 'Failed'}") 160 | 161 | result = False 162 | if is_success: 163 | result = await api.post("Hello World with an image!", image_path="https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/PNG_transparency_demonstration_1.png/280px-PNG_transparency_demonstration_1.png") 164 | 165 | if result: 166 | print("Post has been successfully posted") 167 | else: 168 | print("Unable to post.") 169 | 170 | await api.close_gracefully() 171 | 172 | # Asynchronously posts a message with a URL 173 | async def post_include_url(): 174 | api = ThreadsAPI() 175 | is_success = await api.login(os.environ.get('USERNAME'), os.environ.get('PASSWORD'), cached_token_path=".token") 176 | print(f"Login status: {'Success' if is_success else 'Failed'}") 177 | 178 | result = False 179 | if is_success: 180 | result = await api.post("Hello World with a link!", url="https://threads.net") 181 | 182 | if result: 183 | print("Post has been successfully posted") 184 | else: 185 | print("Unable to post.") 186 | 187 | await api.close_gracefully() 188 | 189 | # Asynchronously follows a user 190 | async def follow_user(): 191 | username_to_follow = "zuck" 192 | 193 | api = ThreadsAPI() 194 | is_success = await api.login(os.environ.get('USERNAME'), os.environ.get('PASSWORD'), cached_token_path=".token") 195 | print(f"Login status: {'Success' if is_success else 'Failed'}") 196 | 197 | result = False 198 | if is_success: 199 | user_id_to_follow = await api.get_user_id_from_username(username_to_follow) 200 | result = await api.follow_user(user_id_to_follow) 201 | 202 | if result: 203 | print(f"Successfully followed {username_to_follow}") 204 | else: 205 | print(f"Unable to follow {username_to_follow}.") 206 | 207 | await api.close_gracefully() 208 | 209 | # Asynchronously unfollows a user 210 | async def unfollow_user(): 211 | username_to_follow = "zuck" 212 | 213 | api = ThreadsAPI() 214 | is_success = await api.login(os.environ.get('USERNAME'), os.environ.get('PASSWORD'), cached_token_path=".token") 215 | print(f"Login status: {'Success' if is_success else 'Failed'}") 216 | 217 | result = False 218 | if is_success: 219 | user_id_to_follow = await api.get_user_id_from_username(username_to_follow) 220 | result = await api.unfollow_user(user_id_to_follow) 221 | 222 | if result: 223 | print(f"Successfully unfollowed {username_to_follow}") 224 | else: 225 | print(f"Unable to unfollow {username_to_follow}.") 226 | 227 | await api.close_gracefully() 228 | 229 | # Code example of logging in while storing token encrypted on the file-system 230 | async def login_with_cache(): 231 | 232 | local_cache_path = ".token" 233 | 234 | api1 = ThreadsAPI() 235 | 236 | # Will login via REST to the Instagram API 237 | is_success = await api1.login(username=os.environ.get('USERNAME'), password=os.environ.get('PASSWORD'), cached_token_path=local_cache_path) 238 | print(f"Login status: {'Success' if is_success else 'Failed'}") 239 | 240 | await api1.close_gracefully() 241 | 242 | api2 = ThreadsAPI() 243 | 244 | # Decrypts the token from the .token file using the password as the key. 245 | # This reduces the number of login API calls, to the bare minimum 246 | is_success = await api2.login(username=os.environ.get('USERNAME'), password=os.environ.get('PASSWORD'), cached_token_path=local_cache_path) 247 | print(f"Login status: {'Success' if is_success else 'Failed'}") 248 | 249 | await api2.close_gracefully() 250 | 251 | async def get_user_followers(): 252 | api = ThreadsAPI() 253 | 254 | # Will login via REST to the Instagram API 255 | is_success = await api.login(username=os.environ.get('USERNAME'), password=os.environ.get('PASSWORD'), cached_token_path=".token") 256 | print(f"Login status: {'Success' if is_success else 'Failed'}") 257 | 258 | if is_success: 259 | username_to_search = "zuck" 260 | number_of_likes_to_display = 10 261 | 262 | user_id_to_search = await api.get_user_id_from_username(username_to_search) 263 | data = await api.get_user_followers(user_id_to_search) 264 | 265 | for user in data['users'][0:number_of_likes_to_display]: 266 | print(f"Username: {user['username']}") 267 | 268 | await api.close_gracefully() 269 | 270 | async def get_user_following(): 271 | api = ThreadsAPI() 272 | 273 | # Will login via REST to the Instagram API 274 | is_success = await api.login(username=os.environ.get('USERNAME'), password=os.environ.get('PASSWORD'), cached_token_path=".token") 275 | print(f"Login status: {'Success' if is_success else 'Failed'}") 276 | 277 | if is_success: 278 | username_to_search = "zuck" 279 | number_of_likes_to_display = 10 280 | 281 | user_id_to_search = await api.get_user_id_from_username(username_to_search) 282 | data = await api.get_user_following(user_id_to_search) 283 | 284 | for user in data['users'][0:number_of_likes_to_display]: 285 | print(f"Username: {user['username']}") 286 | 287 | await api.close_gracefully() 288 | 289 | # Asynchronously likes a post 290 | async def like_post(): 291 | api = ThreadsAPI() 292 | 293 | # Will login via REST to the Instagram API 294 | is_success = await api.login(username=os.environ.get('USERNAME'), password=os.environ.get('PASSWORD'), cached_token_path=".token") 295 | print(f"Login status: {'Success' if is_success else 'Failed'}") 296 | 297 | if is_success: 298 | post_url = "https://www.threads.net/t/CuZsgfWLyiI" 299 | post_id = await api.get_post_id_from_url(post_url) 300 | 301 | result = await api.like_post(post_id) 302 | 303 | print(f"Like status: {'Success' if result else 'Failed'}") 304 | 305 | await api.close_gracefully() 306 | 307 | # Asynchronously unlikes a post 308 | async def unlike_post(): 309 | api = ThreadsAPI() 310 | 311 | # Will login via REST to the Instagram API 312 | is_success = await api.login(username=os.environ.get('USERNAME'), password=os.environ.get('PASSWORD'), cached_token_path=".token") 313 | print(f"Login status: {'Success' if is_success else 'Failed'}") 314 | 315 | if is_success: 316 | post_url = "https://www.threads.net/t/CuZsgfWLyiI" 317 | post_id = await api.get_post_id_from_url(post_url) 318 | 319 | result = await api.unlike_post(post_id) 320 | 321 | print(f"Unlike status: {'Success' if result else 'Failed'}") 322 | 323 | await api.close_gracefully() 324 | 325 | # Asynchronously creates then deletes the same post 326 | async def create_and_delete_post(): 327 | api = ThreadsAPI() 328 | 329 | # Will login via REST to the Instagram API 330 | is_success = await api.login(username=os.environ.get('USERNAME'), password=os.environ.get('PASSWORD'), cached_token_path=".token") 331 | print(f"Login status: {'Success' if is_success else 'Failed'}") 332 | 333 | if is_success: 334 | post_id = await api.post("Hello World!") 335 | await api.delete_post(post_id) 336 | 337 | print(f"Created and deleted post {post_id} successfully") 338 | 339 | await api.close_gracefully() 340 | 341 | # Asynchronously creates then deletes the same post 342 | async def post_and_reply_to_post(): 343 | api = ThreadsAPI() 344 | 345 | # Will login via REST to the Instagram API 346 | is_success = await api.login(username=os.environ.get('USERNAME'), password=os.environ.get('PASSWORD'), cached_token_path=".token") 347 | print(f"Login status: {'Success' if is_success else 'Failed'}") 348 | 349 | if is_success: 350 | first_post_id = await api.post("Hello World!") 351 | second_post_id = await api.post("Hello World to you too!", parent_post_id=first_post_id) 352 | 353 | print(f"Created parent post {first_post_id} and replied to it with post {second_post_id} successfully") 354 | 355 | await api.close_gracefully() 356 | 357 | async def block_and_unblock_user(): 358 | api = ThreadsAPI() 359 | 360 | # Will login via REST to the Instagram API 361 | is_success = await api.login(username=os.environ.get('USERNAME'), password=os.environ.get('PASSWORD'), cached_token_path=".token") 362 | print(f"Login status: {'Success' if is_success else 'Failed'}") 363 | 364 | if is_success: 365 | username = "zuck" 366 | user_id = await api.get_user_id_from_username(username) 367 | resp = await api.block_user(user_id) 368 | 369 | print(f"Blocking status: {'Blocked' if resp['friendship_status']['blocking'] else 'Unblocked'}") 370 | 371 | resp = await api.unblock_user(user_id) 372 | 373 | print(f"Blocking status: {'Blocked' if resp['friendship_status']['blocking'] else 'Unblocked'}") 374 | 375 | await api.close_gracefully() 376 | 377 | return 378 | 379 | async def get_timeline(): 380 | api = ThreadsAPI() 381 | 382 | def _print_post(post): 383 | caption = post['thread_items'][0]['post']['caption'] 384 | 385 | if caption == None: 386 | caption = "" 387 | else: 388 | caption = caption['text'] 389 | print(f"Post -> Caption: [{caption}]\n") 390 | 391 | async def _print_posts_in_feed(next_max_id=None, posts_to_go=0): 392 | if posts_to_go > 0: 393 | if next_max_id is not None: 394 | resp = await api.get_timeline(next_max_id) 395 | else: 396 | resp = await api.get_timeline() 397 | 398 | for post in resp['items'][:resp['num_results']]: 399 | _print_post(post) 400 | 401 | posts_to_go -= resp['num_results'] 402 | await _print_posts_in_feed(resp['next_max_id'], posts_to_go) 403 | 404 | 405 | # Will login via REST to the Instagram API 406 | is_success = await api.login(username=os.environ.get('USERNAME'), password=os.environ.get('PASSWORD'), cached_token_path=".token") 407 | print(f"Login status: {'Success' if is_success else 'Failed'}") 408 | 409 | if is_success: 410 | await _print_posts_in_feed(posts_to_go=20) 411 | 412 | await api.close_gracefully() 413 | 414 | return 415 | 416 | async def get_timeline_with_api(api=ThreadsAPI()): 417 | def _print_post(post): 418 | caption = post['thread_items'][0]['post']['caption'] 419 | 420 | if caption == None: 421 | caption = "" 422 | else: 423 | caption = caption['text'] 424 | print(f"Post -> Caption: [{caption}]\n") 425 | 426 | async def _print_posts_in_feed(next_max_id=None, posts_to_go=0): 427 | if posts_to_go > 0: 428 | if next_max_id is not None: 429 | resp = await api.get_timeline(next_max_id) 430 | else: 431 | resp = await api.get_timeline() 432 | 433 | for post in resp['items'][:resp['num_results']]: 434 | _print_post(post) 435 | 436 | posts_to_go -= resp['num_results'] 437 | await _print_posts_in_feed(resp['next_max_id'], posts_to_go) 438 | 439 | 440 | # Will login via REST to the Instagram API 441 | is_success = await api.login(username=os.environ.get('USERNAME'), password=os.environ.get('PASSWORD'), cached_token_path=".token") 442 | print(f"Login status: {'Success' if is_success else 'Failed'}") 443 | 444 | if is_success: 445 | await _print_posts_in_feed(posts_to_go=20) 446 | 447 | await api.close_gracefully() 448 | 449 | return 450 | 451 | # Asynchronously gets the threads for a user 452 | async def get_user_threads_while_authenticated(): 453 | api = ThreadsAPI() 454 | 455 | username = "zuck" 456 | user_id = await api.get_user_id_from_username(username) 457 | 458 | # Will login via REST to the Instagram API 459 | is_success = await api.login(username=os.environ.get('USERNAME'), password=os.environ.get('PASSWORD'), cached_token_path=".token") 460 | print(f"Login status: {'Success' if is_success else 'Failed'}") 461 | 462 | if is_success: 463 | if user_id: 464 | resp = await api.get_user_threads(user_id) 465 | print(f"The threads for user '{username}' are:") 466 | for thread in resp['threads']: 467 | print(f"{username}'s Post: {thread['thread_items'][0]['post']['caption']['text']} || Likes: {thread['thread_items'][0]['post']['like_count']}") 468 | else: 469 | print(f"User ID not found for username '{username}'") 470 | 471 | await api.close_gracefully() 472 | 473 | # Asynchronously gets the replies for a user 474 | async def get_user_replies_while_authenticated(): 475 | api = ThreadsAPI() 476 | 477 | username = "zuck" 478 | user_id = await api.get_user_id_from_username(username) 479 | 480 | # Will login via REST to the Instagram API 481 | is_success = await api.login(username=os.environ.get('USERNAME'), password=os.environ.get('PASSWORD'), cached_token_path=".token") 482 | print(f"Login status: {'Success' if is_success else 'Failed'}") 483 | 484 | if is_success: 485 | if user_id: 486 | threads = await api.get_user_replies(user_id) 487 | print(f"The replies for user '{username}' are:") 488 | for thread in threads['threads']: 489 | print(f"-\n{thread['thread_items'][0]['post']['user']['username']}'s Post: {thread['thread_items'][0]['post']['caption']['text']} || Likes: {thread['thread_items'][0]['post']['like_count']}") 490 | 491 | print(f"{username}'s Reply: {thread['thread_items'][1]['post']['caption']['text']} || Likes: {thread['thread_items'][1]['post']['like_count']}\n-") 492 | else: 493 | print(f"User ID not found for username '{username}'") 494 | 495 | await api.close_gracefully() 496 | 497 | ''' 498 | Remove the # to run an individual example function wrapper. 499 | 500 | Each line below is standalone, and does not depend on the other. 501 | ''' 502 | ##### Do not require login ##### 503 | 504 | #asyncio.run(get_user_id_from_username()) # Retrieves the user ID for a given username. 505 | #asyncio.run(get_user_profile()) # Retrieves the threads associated with a user. 506 | #asyncio.run(get_user_threads()) # Retrieves the replies made by a user. 507 | #asyncio.run(get_user_replies()) # Retrieves the profile information of a user. 508 | #asyncio.run(get_post_id_from_url()) # Retrieves the post ID from a given URL. 509 | #asyncio.run(get_post()) # Retrieves a post and its associated replies. 510 | #asyncio.run(get_post_likes()) # Retrieves the likes for a post. 511 | 512 | ##### Require login (included) ##### 513 | 514 | #asyncio.run(post()) # Posts a message. 515 | #asyncio.run(post_include_image()) # Posts a message with an image. 516 | #asyncio.run(post_include_image_from_url()) # Posts a message with an image. 517 | #asyncio.run(post_include_url()) # Posts a message with a URL. 518 | #asyncio.run(follow_user()) # Follows a user. 519 | #asyncio.run(unfollow_user()) # Unfollows a user. 520 | #asyncio.run(login_with_cache()) # Displays token cache capability 521 | #asyncio.run(get_user_followers()) # Displays users who follow a given user 522 | #asyncio.run(like_post()) # Likes a post 523 | #asyncio.run(unlike_post()) # Unlikes a post 524 | #asyncio.run(create_and_delete_post()) # Creates and deletes the same post 525 | #asyncio.run(post_and_reply_to_post()) # Post and then reply to the same post 526 | #asyncio.run(block_and_unblock_user()) # Blocks and unblocks a user 527 | #asyncio.run(get_timeline()) # Display items from the timeline 528 | #asyncio.run(get_user_threads_while_authenticated()) # Retrieves the replies made by a user. 529 | #asyncio.run(get_user_replies_while_authenticated()) # Retrieves the profile information of a user. 530 | 531 | #asyncio.run(get_timeline_with_api(ThreadsAPI(http_session_class=AioHTTPSession))) # Use aiohttp session under the hood 532 | #asyncio.run(get_timeline_with_api(ThreadsAPI(http_session_class=RequestsSession))) # Use requests session under the hood 533 | #asyncio.run(get_timeline_with_api(ThreadsAPI(http_session_class=InstagrapiSession))) # Use instagrapi session under the hood -------------------------------------------------------------------------------- /examples/private_api_examples.py: -------------------------------------------------------------------------------- 1 | from threads_api.src.threads_api import ThreadsAPI 2 | import asyncio 3 | import os 4 | from threads_api.src.http_sessions.instagrapi_session import InstagrapiSession 5 | from threads_api.src.http_sessions.requests_session import RequestsSession 6 | from threads_api.src.http_sessions.aiohttp_session import AioHTTPSession 7 | 8 | # Code example of logging in while storing token encrypted on the file-system 9 | async def login_with_cache(): 10 | 11 | local_cache_path = ".token" 12 | 13 | api1 = ThreadsAPI() 14 | 15 | # Will login via REST to the Instagram API 16 | is_success = await api1.login(username=os.environ.get('USERNAME'), password=os.environ.get('PASSWORD'), cached_token_path=local_cache_path) 17 | print(f"Login status: {'Success' if is_success else 'Failed'}") 18 | 19 | await api1.close_gracefully() 20 | 21 | api2 = ThreadsAPI() 22 | 23 | # Decrypts the token from the .token file using the password as the key. 24 | # This reduces the number of login API calls, to the bare minimum 25 | is_success = await api2.login(username=os.environ.get('USERNAME'), password=os.environ.get('PASSWORD'), cached_token_path=local_cache_path) 26 | print(f"Login status: {'Success' if is_success else 'Failed'}") 27 | 28 | await api2.close_gracefully() 29 | 30 | # Asynchronously posts a message 31 | async def post(api): 32 | result = await api.post("Hello World!") 33 | 34 | if result.media.pk: 35 | print(f"Post has been successfully posted with id: [{result.media.pk}]") 36 | else: 37 | print("Unable to post.") 38 | 39 | # Asynchronously posts a message 40 | async def post_and_quote(api): 41 | post_url = "https://www.threads.net/t/CuZsgfWLyiI" 42 | post_id = await api.get_post_id_from_url(post_url) 43 | 44 | result = await api.post("What do you think of this?", quoted_post_id=post_id) 45 | 46 | if result.media.pk: 47 | print(f"Post has been successfully posted with id: [{result.media.pk}]") 48 | else: 49 | print("Unable to post.") 50 | 51 | 52 | # Asynchronously posts a message with an image 53 | async def post_include_image(api): 54 | result = await api.post("Hello World with an image!", image_path=".github/logo.jpg") 55 | 56 | if result.media.pk: 57 | print(f"Post has been successfully posted with id: [{result.media.pk}]") 58 | else: 59 | print("Unable to post.") 60 | 61 | # Asynchronously posts a message with an image 62 | async def post_include_image_from_url(api): 63 | result = await api.post("Hello World with an image!", image_path="https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/PNG_transparency_demonstration_1.png/280px-PNG_transparency_demonstration_1.png") 64 | 65 | if result.media.pk: 66 | print(f"Post has been successfully posted with id: [{result.media.pk}]") 67 | else: 68 | print("Unable to post.") 69 | 70 | # Asynchronously posts a message with an image 71 | async def post_include_multiple_images(api): 72 | result = await api.post("Hello World with an image!", image_path=[".github/logo.jpg", 73 | "https://upload.wikimedia.org/wikipedia/commons/b/b5/Baby.tux.sit-black-800x800.png", 74 | "https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/PNG_transparency_demonstration_1.png/280px-PNG_transparency_demonstration_1.png"]) 75 | 76 | if result.media.pk: 77 | print(f"Post has been successfully posted with id: [{result.media.pk}]") 78 | else: 79 | print("Unable to post.") 80 | 81 | 82 | # Asynchronously posts a message with a URL 83 | async def post_include_url(api): 84 | result = False 85 | result = await api.post("Hello World with a link!", url="https://threads.net") 86 | 87 | if result.media.pk: 88 | print(f"Post has been successfully posted with id: [{result.media.pk}]") 89 | else: 90 | print("Unable to post.") 91 | 92 | # Asynchronously follows a user 93 | async def follow_user(api): 94 | username_to_follow = "zuck" 95 | 96 | user_id_to_follow = await api.get_user_id_from_username(username_to_follow) 97 | result = await api.follow_user(user_id_to_follow) 98 | 99 | if result: 100 | print(f"Successfully followed {username_to_follow}") 101 | else: 102 | print(f"Unable to follow {username_to_follow}.") 103 | 104 | 105 | 106 | # Asynchronously unfollows a user 107 | async def unfollow_user(api): 108 | username_to_follow = "zuck" 109 | 110 | user_id_to_follow = await api.get_user_id_from_username(username_to_follow) 111 | result = await api.unfollow_user(user_id_to_follow) 112 | 113 | if result: 114 | print(f"Successfully unfollowed {username_to_follow}") 115 | else: 116 | print(f"Unable to unfollow {username_to_follow}.") 117 | 118 | async def get_user_followers(api): 119 | username_to_search = "zuck" 120 | number_of_likes_to_display = 10 121 | 122 | user_id_to_search = await api.get_user_id_from_username(username_to_search) 123 | data = await api.get_user_followers(user_id_to_search) 124 | 125 | for user in data['users'][0:number_of_likes_to_display]: 126 | print(f"Username: {user['username']}") 127 | 128 | async def get_user_following(api): 129 | username_to_search = "zuck" 130 | number_of_likes_to_display = 10 131 | 132 | user_id_to_search = await api.get_user_id_from_username(username_to_search) 133 | data = await api.get_user_following(user_id_to_search) 134 | 135 | for user in data['users'][0:number_of_likes_to_display]: 136 | print(f"Username: {user['username']}") 137 | 138 | # Asynchronously likes a post 139 | async def like_post(api): 140 | post_url = "https://www.threads.net/t/CuZsgfWLyiI" 141 | post_id = await api.get_post_id_from_url(post_url) 142 | 143 | result = await api.like_post(post_id) 144 | 145 | print(f"Like status: {'Success' if result else 'Failed'}") 146 | 147 | # Asynchronously unlikes a post 148 | async def unlike_post(api): 149 | post_url = "https://www.threads.net/t/CuZsgfWLyiI" 150 | post_id = await api.get_post_id_from_url(post_url) 151 | 152 | result = await api.unlike_post(post_id) 153 | 154 | print(f"Unlike status: {'Success' if result else 'Failed'}") 155 | 156 | # Asynchronously creates then deletes the same post 157 | async def create_and_delete_post(api): 158 | post_id = await api.post("Hello World!") 159 | await api.delete_post(post_id) 160 | 161 | print(f"Created and deleted post {post_id} successfully") 162 | 163 | # Asynchronously creates then deletes the same post 164 | async def post_and_reply_to_post(api): 165 | first_post_id = await api.post("Hello World!") 166 | second_post_id = await api.post("Hello World to you too!", parent_post_id=first_post_id) 167 | 168 | print(f"Created parent post {first_post_id} and replied to it with post {second_post_id} successfully") 169 | 170 | async def block_and_unblock_user(api): 171 | username = "zuck" 172 | user_id = await api.get_user_id_from_username(username) 173 | resp = await api.block_user(user_id) 174 | 175 | print(f"Blocking status: {'Blocked' if resp['friendship_status']['blocking'] else 'Unblocked'}") 176 | 177 | resp = await api.unblock_user(user_id) 178 | 179 | print(f"Blocking status: {'Blocked' if resp['friendship_status']['blocking'] else 'Unblocked'}") 180 | 181 | return 182 | 183 | async def get_timeline(api): 184 | def _print_post(timeline_item): 185 | caption = timeline_item.thread_items[0].post.caption 186 | 187 | if caption == None: 188 | caption = "" 189 | else: 190 | caption = caption.text 191 | print(f"Post -> Caption: [{caption}]\n") 192 | 193 | async def _print_posts_in_feed(next_max_id=None, posts_to_go=0): 194 | if posts_to_go > 0: 195 | if next_max_id is not None: 196 | resp = await api.get_timeline(next_max_id) 197 | else: 198 | resp = await api.get_timeline() 199 | 200 | 201 | for timeline_item in resp.items[:resp.num_results]: 202 | _print_post(timeline_item) 203 | 204 | posts_to_go -= resp.num_results 205 | await _print_posts_in_feed(resp.next_max_id, posts_to_go) 206 | 207 | await _print_posts_in_feed(posts_to_go=20) 208 | 209 | return 210 | 211 | # Asynchronously gets the threads for a user 212 | async def get_user_threads(api): 213 | username = "zuck" 214 | user_id = await api.get_user_id_from_username(username) 215 | 216 | if user_id: 217 | threads = await api.get_user_threads(user_id) 218 | 219 | print(f"The threads for user '{username}' are:") 220 | for thread in threads.threads: 221 | for thread_item in thread.thread_items: 222 | print(f"{username}'s Post: {thread_item.post.caption.text} || Likes: {thread_item.post.like_count}") 223 | else: 224 | print(f"User ID not found for username '{username}'") 225 | 226 | # Asynchronously gets the replies for a user 227 | async def get_user_replies(api : ThreadsAPI): 228 | username = "zuck" 229 | user_id = await api.get_user_id_from_username(username) 230 | 231 | if user_id: 232 | threads = await api.get_user_replies(user_id) 233 | print(f"The replies for user '{username}' are:") 234 | for thread in threads.threads: 235 | post = thread.thread_items[0].post 236 | print(f"-\n{post.user.username}'s Post: {post.caption.text} || Likes: {post.like_count}") 237 | 238 | if len(thread.thread_items) > 1: 239 | first_reply = thread.thread_items[1].post 240 | print(f"{username}'s Reply: {first_reply.caption.text} || Likes: {first_reply.like_count}\n-") 241 | else: 242 | print(f"-> You will need to sign up / login to see more.") 243 | 244 | else: 245 | print(f"User ID not found for username '{username}'") 246 | 247 | # Asynchronously gets a post 248 | async def get_post(api : ThreadsAPI): 249 | post_url = "https://www.threads.net/t/CuZsgfWLyiI" 250 | 251 | post_id = await api.get_post_id_from_url(post_url) 252 | 253 | response = await api.get_post(post_id) 254 | 255 | thread = response.containing_thread.thread_items[0].post 256 | print(f"{thread.user.username}'s post {thread.caption.text}: || Likes: {thread.like_count}") 257 | 258 | for reply in response.reply_threads: 259 | if reply.thread_items is not None and len(reply.thread_items) >= 1: 260 | print(f"-\n{reply.thread_items[0].post.user.username}'s Reply: {reply.thread_items[0].post.caption.text} || Likes: {reply.thread_items[0].post.like_count}") 261 | 262 | # Asynchronously gets the likes for a post 263 | async def get_post_likes(api : ThreadsAPI): 264 | post_url = "https://www.threads.net/t/CuZsgfWLyiI" 265 | 266 | post_id = await api.get_post_id_from_url(post_url) 267 | 268 | users_list = await api.get_post_likes(post_id) 269 | number_of_likes_to_display = 10 270 | 271 | for user in users_list.users[:number_of_likes_to_display]: 272 | print(f'Username: {user.username} || Full Name: {user.full_name} || Follower Count: {user.follower_count} ') 273 | 274 | # Asynchronously reposts and deletes the repost 275 | async def repost_and_delete(api : ThreadsAPI): 276 | post_url = "https://www.threads.net/t/Cu0BgHESnwF" 277 | 278 | post_id = await api.get_post_id_from_url(post_url) 279 | await api.repost(post_id) 280 | await api.delete_repost(post_id) 281 | 282 | # Asynchronously search users by query 283 | async def search_user(api : ThreadsAPI): 284 | res = await api.search_user(query="zuck") 285 | 286 | index = 1 287 | for user in res['users']: 288 | print(f"#{index} -> Username:[{user['username']}] Followers: [{user['follower_count']}] Following: [{user['following_count']}]") 289 | index += 1 290 | 291 | # Asynchronously gets a list of recommended users 292 | async def get_recommended_users(api : ThreadsAPI): 293 | res = await api.get_recommended_users() 294 | 295 | index = 1 296 | for user in res['users']: 297 | print(f"#{index} -> Username:[{user['username']}] Full Name: [{user['full_name']}] Followers: [{user['follower_count']}]") 298 | index += 1 299 | 300 | # Asynchronously gets a list of recommended users 301 | async def get_notifications(api : ThreadsAPI): 302 | res = await api.get_notifications() 303 | 304 | print(res) 305 | 306 | 307 | async def main(): 308 | supported_http_session_classes = [AioHTTPSession, RequestsSession, InstagrapiSession] 309 | 310 | # Run the API calls using each of the Session types 311 | for http_session_class in supported_http_session_classes: 312 | api = ThreadsAPI(http_session_class=http_session_class) 313 | 314 | # Will login via REST to the Instagram API 315 | is_success = await api.login(username=os.environ.get('USERNAME'), password=os.environ.get('PASSWORD'), cached_token_path=".token") 316 | print(f"Login status: {'Success' if is_success else 'Failed'}") 317 | 318 | if is_success: 319 | print(f"Executing API calls using [{http_session_class}] session.") 320 | #await post(api) # Posts a message. 321 | #await post_and_quote(api) # Post a message and quote another post 322 | #await post_include_image(api) # Posts a message with an image. 323 | #await post_include_image_from_url(api) # Posts a message with an image. 324 | #await post_include_multiple_images(api) # Post with multiple images 325 | #await post_include_url(api) # Posts a message with a URL. 326 | #await follow_user(api) # Follows a user. 327 | #await unfollow_user(api) # Unfollows a user. 328 | #await get_user_followers(api) # Displays users who follow a given user 329 | #await like_post(api) # Likes a post 330 | #await unlike_post(api) # Unlikes a post 331 | #await create_and_delete_post(api) # Creates and deletes the same post 332 | #await post_and_reply_to_post(api) # Post and then reply to the same post 333 | #await block_and_unblock_user(api) # Blocks and unblocks a user 334 | #await get_timeline(api) # Display items from the timeline 335 | #await get_user_threads(api) # Retrieves the replies made by a user. 336 | #await get_user_replies(api) # Retrieves the profile information of a user. 337 | #await get_post(api) # Retrieves a post and its associated replies. 338 | #await get_post_likes(api) # Get likers of a post 339 | #await repost_and_delete(api) # Repost a post and delete it immediately 340 | #await search_user(api) # Search for users by query 341 | #await get_recommended_users(api) # Get a list of recommended users 342 | #await get_notifications(api) # Get a list of notifications 343 | 344 | await api.close_gracefully() 345 | 346 | if __name__ == "__main__": 347 | asyncio.run(main()) -------------------------------------------------------------------------------- /examples/public_api_examples.py: -------------------------------------------------------------------------------- 1 | from threads_api.src.threads_api import ThreadsAPI 2 | import asyncio 3 | import os 4 | import logging 5 | from threads_api.src.http_sessions.instagrapi_session import InstagrapiSession 6 | from threads_api.src.http_sessions.requests_session import RequestsSession 7 | from threads_api.src.http_sessions.aiohttp_session import AioHTTPSession 8 | 9 | from threads_api.src.anotherlogger import format_log 10 | 11 | # Asynchronously gets the user ID from a username 12 | async def get_user_id_from_username(api : ThreadsAPI): 13 | username = "zuck" 14 | user_id = await api.get_user_id_from_username(username) 15 | 16 | if user_id: 17 | print(f"The user ID for username '{username}' is: {user_id}") 18 | else: 19 | print(f"User ID not found for username '{username}'") 20 | 21 | # Asynchronously gets the threads for a user 22 | async def get_user_threads(api : ThreadsAPI): 23 | username = "zuck" 24 | user_id = await api.get_user_id_from_username(username) 25 | 26 | if user_id: 27 | threads = await api.get_user_threads(user_id) 28 | 29 | print(f"The threads for user '{username}' are:") 30 | for thread in threads.threads: 31 | for thread_item in thread.thread_items: 32 | print(f"{username}'s Post: {thread_item.post.caption.text} || Likes: {thread_item.post.like_count}") 33 | else: 34 | print(f"User ID not found for username '{username}'") 35 | 36 | # Asynchronously gets the replies for a user 37 | async def get_user_replies(api : ThreadsAPI): 38 | username = "zuck" 39 | user_id = await api.get_user_id_from_username(username) 40 | 41 | if user_id: 42 | threads = await api.get_user_replies(user_id) 43 | print(f"The replies for user '{username}' are:") 44 | for thread in threads.threads: 45 | post = thread.thread_items[0].post 46 | print(f"-\n{post.user.username}'s Post: {post.caption.text} || Likes: {post.like_count}") 47 | 48 | if len(thread.thread_items) > 1: 49 | first_reply = thread.thread_items[1].post 50 | print(f"{username}'s Reply: {first_reply.caption.text} || Likes: {first_reply.like_count}\n-") 51 | else: 52 | print(f"-> You will need to sign up / login to see more.") 53 | 54 | else: 55 | print(f"User ID not found for username '{username}'") 56 | 57 | # Asynchronously gets the user profile 58 | async def get_user_profile(api : ThreadsAPI): 59 | 60 | username = "zuck" 61 | user_id = await api.get_user_id_from_username(username) 62 | 63 | if user_id: 64 | user_profile = await api.get_user_profile(user_id) 65 | print(f"User profile for '{username}':") 66 | print(f"Name: {user_profile.username}") 67 | print(f"Bio: {user_profile.biography}") 68 | print(f"Followers: {user_profile.follower_count}") 69 | else: 70 | print(f"User ID not found for username '{username}'") 71 | 72 | # Asynchronously gets the post ID from a URL 73 | async def get_post_id_from_url(api : ThreadsAPI): 74 | post_url = "https://www.threads.net/t/CuZsgfWLyiI" 75 | 76 | post_id = await api.get_post_id_from_url(post_url) 77 | print(f"Thread post_id is {post_id}") 78 | 79 | # Asynchronously gets a post 80 | async def get_post(api : ThreadsAPI): 81 | post_url = "https://www.threads.net/t/CuZsgfWLyiI" 82 | 83 | post_id = await api.get_post_id_from_url(post_url) 84 | 85 | response = await api.get_post(post_id) 86 | 87 | thread = response.containing_thread.thread_items[0].post 88 | print(f"{thread.user.username}'s post {thread.caption.text}: || Likes: {thread.like_count}") 89 | 90 | for reply in response.reply_threads: 91 | if reply.thread_items is not None and len(reply.thread_items) >= 1: 92 | print(f"-\n{reply.thread_items[0].post.user.username}'s Reply: {reply.thread_items[0].post.caption.text} || Likes: {reply.thread_items[0].post.like_count}") 93 | 94 | # Asynchronously gets the likes for a post 95 | async def get_post_likes(api : ThreadsAPI): 96 | post_url = "https://www.threads.net/t/CuZsgfWLyiI" 97 | 98 | post_id = await api.get_post_id_from_url(post_url) 99 | 100 | users_list = await api.get_post_likes(post_id) 101 | number_of_likes_to_display = 10 102 | 103 | for user in users_list.users[:number_of_likes_to_display]: 104 | print(f'Username: {user.username} || Full Name: {user.full_name} || Follower Count: {user.follower_count} ') 105 | 106 | ''' 107 | Remove the # to run an individual example function wrapper. 108 | 109 | Each line below is standalone, and does not depend on the other. 110 | ''' 111 | ##### Do not require login ##### 112 | 113 | async def main(): 114 | supported_http_session_classes = [AioHTTPSession, RequestsSession, InstagrapiSession] 115 | 116 | # Run the API calls using each of the Session types 117 | for http_session_class in supported_http_session_classes: 118 | api = ThreadsAPI(http_session_class=http_session_class) 119 | 120 | print(f"Executing API calls using [{http_session_class}] session.") 121 | #await get_user_id_from_username(api) # Retrieves the user ID for a given username. 122 | #await get_user_profile(api) # Retrieves the threads associated with a user. 123 | #await get_user_threads(api) # Retrieves the replies made by a user. 124 | await get_user_replies(api) # Retrieves the profile information of a user. 125 | #await get_post_id_from_url(api) # Retrieves the post ID from a given URL. 126 | #await get_post(api) # Retrieves a post and its associated replies. 127 | #await get_post_likes(api) # Retrieves the likes for a post. 128 | 129 | await api.close_gracefully() 130 | 131 | if __name__ == "__main__": 132 | asyncio.run(main()) 133 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "aiohttp" 3 | version = "3.8.4" 4 | description = "Async http client/server framework (asyncio)" 5 | category = "main" 6 | optional = false 7 | python-versions = ">=3.6" 8 | 9 | [package.dependencies] 10 | aiosignal = ">=1.1.2" 11 | async-timeout = ">=4.0.0a3,<5.0" 12 | attrs = ">=17.3.0" 13 | charset-normalizer = ">=2.0,<4.0" 14 | frozenlist = ">=1.1.1" 15 | multidict = ">=4.5,<7.0" 16 | yarl = ">=1.0,<2.0" 17 | 18 | [package.extras] 19 | speedups = ["aiodns", "brotli", "cchardet"] 20 | 21 | [[package]] 22 | name = "aiosignal" 23 | version = "1.3.1" 24 | description = "aiosignal: a list of registered asynchronous callbacks" 25 | category = "main" 26 | optional = false 27 | python-versions = ">=3.7" 28 | 29 | [package.dependencies] 30 | frozenlist = ">=1.1.0" 31 | 32 | [[package]] 33 | name = "async-timeout" 34 | version = "4.0.2" 35 | description = "Timeout context manager for asyncio programs" 36 | category = "main" 37 | optional = false 38 | python-versions = ">=3.6" 39 | 40 | [[package]] 41 | name = "atomicwrites" 42 | version = "1.4.1" 43 | description = "Atomic file writes." 44 | category = "main" 45 | optional = false 46 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 47 | 48 | [[package]] 49 | name = "attrs" 50 | version = "23.1.0" 51 | description = "Classes Without Boilerplate" 52 | category = "main" 53 | optional = false 54 | python-versions = ">=3.7" 55 | 56 | [package.extras] 57 | cov = ["attrs", "coverage[toml] (>=5.3)"] 58 | dev = ["attrs", "pre-commit"] 59 | docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] 60 | tests = ["attrs", "zope-interface"] 61 | tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pytest-mypy-plugins", "pytest-xdist", "pytest (>=4.3.0)"] 62 | 63 | [[package]] 64 | name = "certifi" 65 | version = "2023.5.7" 66 | description = "Python package for providing Mozilla's CA Bundle." 67 | category = "main" 68 | optional = false 69 | python-versions = ">=3.6" 70 | 71 | [[package]] 72 | name = "cffi" 73 | version = "1.15.1" 74 | description = "Foreign Function Interface for Python calling C code." 75 | category = "main" 76 | optional = false 77 | python-versions = "*" 78 | 79 | [package.dependencies] 80 | pycparser = "*" 81 | 82 | [[package]] 83 | name = "charset-normalizer" 84 | version = "3.2.0" 85 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 86 | category = "main" 87 | optional = false 88 | python-versions = ">=3.7.0" 89 | 90 | [[package]] 91 | name = "colorama" 92 | version = "0.4.6" 93 | description = "Cross-platform colored terminal text." 94 | category = "main" 95 | optional = false 96 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 97 | 98 | [[package]] 99 | name = "cryptography" 100 | version = "41.0.2" 101 | description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." 102 | category = "main" 103 | optional = false 104 | python-versions = ">=3.7" 105 | 106 | [package.dependencies] 107 | cffi = ">=1.12" 108 | 109 | [package.extras] 110 | docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] 111 | docstest = ["pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"] 112 | nox = ["nox"] 113 | pep8test = ["black", "ruff", "mypy", "check-sdist"] 114 | sdist = ["build"] 115 | ssh = ["bcrypt (>=3.1.5)"] 116 | test = ["pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist", "pretend"] 117 | test-randomorder = ["pytest-randomly"] 118 | 119 | [[package]] 120 | name = "flake8" 121 | version = "6.0.0" 122 | description = "the modular source code checker: pep8 pyflakes and co" 123 | category = "main" 124 | optional = false 125 | python-versions = ">=3.8.1" 126 | 127 | [package.dependencies] 128 | mccabe = ">=0.7.0,<0.8.0" 129 | pycodestyle = ">=2.10.0,<2.11.0" 130 | pyflakes = ">=3.0.0,<3.1.0" 131 | 132 | [[package]] 133 | name = "frozenlist" 134 | version = "1.3.3" 135 | description = "A list-like structure which implements collections.abc.MutableSequence" 136 | category = "main" 137 | optional = false 138 | python-versions = ">=3.7" 139 | 140 | [[package]] 141 | name = "idna" 142 | version = "3.4" 143 | description = "Internationalized Domain Names in Applications (IDNA)" 144 | category = "main" 145 | optional = false 146 | python-versions = ">=3.5" 147 | 148 | [[package]] 149 | name = "iniconfig" 150 | version = "2.0.0" 151 | description = "brain-dead simple config-ini parsing" 152 | category = "main" 153 | optional = false 154 | python-versions = ">=3.7" 155 | 156 | [[package]] 157 | name = "instagrapi" 158 | version = "1.17.13" 159 | description = "Fast and effective Instagram Private API wrapper" 160 | category = "main" 161 | optional = false 162 | python-versions = ">=3.8" 163 | 164 | [package.dependencies] 165 | pycryptodomex = "3.18.0" 166 | pydantic = "1.10.9" 167 | PySocks = "1.7.1" 168 | requests = ">=2.25.1,<3.0" 169 | 170 | [[package]] 171 | name = "mccabe" 172 | version = "0.7.0" 173 | description = "McCabe checker, plugin for flake8" 174 | category = "main" 175 | optional = false 176 | python-versions = ">=3.6" 177 | 178 | [[package]] 179 | name = "multidict" 180 | version = "6.0.4" 181 | description = "multidict implementation" 182 | category = "main" 183 | optional = false 184 | python-versions = ">=3.7" 185 | 186 | [[package]] 187 | name = "packaging" 188 | version = "23.1" 189 | description = "Core utilities for Python packages" 190 | category = "main" 191 | optional = false 192 | python-versions = ">=3.7" 193 | 194 | [[package]] 195 | name = "pillow" 196 | version = "10.0.0" 197 | description = "Python Imaging Library (Fork)" 198 | category = "main" 199 | optional = false 200 | python-versions = ">=3.8" 201 | 202 | [package.extras] 203 | docs = ["furo", "olefile", "sphinx (>=2.4)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinx-removed-in", "sphinxext-opengraph"] 204 | tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] 205 | 206 | [[package]] 207 | name = "pluggy" 208 | version = "1.2.0" 209 | description = "plugin and hook calling mechanisms for python" 210 | category = "main" 211 | optional = false 212 | python-versions = ">=3.7" 213 | 214 | [package.extras] 215 | dev = ["pre-commit", "tox"] 216 | testing = ["pytest", "pytest-benchmark"] 217 | 218 | [[package]] 219 | name = "py" 220 | version = "1.11.0" 221 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 222 | category = "main" 223 | optional = false 224 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 225 | 226 | [[package]] 227 | name = "pycodestyle" 228 | version = "2.10.0" 229 | description = "Python style guide checker" 230 | category = "main" 231 | optional = false 232 | python-versions = ">=3.6" 233 | 234 | [[package]] 235 | name = "pycparser" 236 | version = "2.21" 237 | description = "C parser in Python" 238 | category = "main" 239 | optional = false 240 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 241 | 242 | [[package]] 243 | name = "pycryptodomex" 244 | version = "3.18.0" 245 | description = "Cryptographic library for Python" 246 | category = "main" 247 | optional = false 248 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 249 | 250 | [[package]] 251 | name = "pydantic" 252 | version = "1.10.9" 253 | description = "Data validation and settings management using python type hints" 254 | category = "main" 255 | optional = false 256 | python-versions = ">=3.7" 257 | 258 | [package.dependencies] 259 | typing-extensions = ">=4.2.0" 260 | 261 | [package.extras] 262 | dotenv = ["python-dotenv (>=0.10.4)"] 263 | email = ["email-validator (>=1.0.3)"] 264 | 265 | [[package]] 266 | name = "pyflakes" 267 | version = "3.0.1" 268 | description = "passive checker of Python programs" 269 | category = "main" 270 | optional = false 271 | python-versions = ">=3.6" 272 | 273 | [[package]] 274 | name = "pysocks" 275 | version = "1.7.1" 276 | description = "A Python SOCKS client module. See https://github.com/Anorov/PySocks for more information." 277 | category = "main" 278 | optional = false 279 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 280 | 281 | [[package]] 282 | name = "pytest" 283 | version = "6.2.5" 284 | description = "pytest: simple powerful testing with Python" 285 | category = "main" 286 | optional = false 287 | python-versions = ">=3.6" 288 | 289 | [package.dependencies] 290 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 291 | attrs = ">=19.2.0" 292 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 293 | iniconfig = "*" 294 | packaging = "*" 295 | pluggy = ">=0.12,<2.0" 296 | py = ">=1.8.2" 297 | toml = "*" 298 | 299 | [package.extras] 300 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] 301 | 302 | [[package]] 303 | name = "pytest-asyncio" 304 | version = "0.15.1" 305 | description = "Pytest support for asyncio." 306 | category = "main" 307 | optional = false 308 | python-versions = ">= 3.6" 309 | 310 | [package.dependencies] 311 | pytest = ">=5.4.0" 312 | 313 | [package.extras] 314 | testing = ["coverage", "hypothesis (>=5.7.1)"] 315 | 316 | [[package]] 317 | name = "pytest-mock" 318 | version = "3.11.1" 319 | description = "Thin-wrapper around the mock package for easier use with pytest" 320 | category = "main" 321 | optional = false 322 | python-versions = ">=3.7" 323 | 324 | [package.dependencies] 325 | pytest = ">=5.0" 326 | 327 | [package.extras] 328 | dev = ["pre-commit", "tox", "pytest-asyncio"] 329 | 330 | [[package]] 331 | name = "pytz" 332 | version = "2023.3" 333 | description = "World timezone definitions, modern and historical" 334 | category = "main" 335 | optional = false 336 | python-versions = "*" 337 | 338 | [[package]] 339 | name = "requests" 340 | version = "2.31.0" 341 | description = "Python HTTP for Humans." 342 | category = "main" 343 | optional = false 344 | python-versions = ">=3.7" 345 | 346 | [package.dependencies] 347 | certifi = ">=2017.4.17" 348 | charset-normalizer = ">=2,<4" 349 | idna = ">=2.5,<4" 350 | urllib3 = ">=1.21.1,<3" 351 | 352 | [package.extras] 353 | socks = ["PySocks (>=1.5.6,!=1.5.7)"] 354 | use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] 355 | 356 | [[package]] 357 | name = "toml" 358 | version = "0.10.2" 359 | description = "Python Library for Tom's Obvious, Minimal Language" 360 | category = "main" 361 | optional = false 362 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 363 | 364 | [[package]] 365 | name = "typing-extensions" 366 | version = "4.7.1" 367 | description = "Backported and Experimental Type Hints for Python 3.7+" 368 | category = "main" 369 | optional = false 370 | python-versions = ">=3.7" 371 | 372 | [[package]] 373 | name = "urllib3" 374 | version = "2.0.3" 375 | description = "HTTP library with thread-safe connection pooling, file post, and more." 376 | category = "main" 377 | optional = false 378 | python-versions = ">=3.7" 379 | 380 | [package.extras] 381 | brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] 382 | secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"] 383 | socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] 384 | zstd = ["zstandard (>=0.18.0)"] 385 | 386 | [[package]] 387 | name = "yarl" 388 | version = "1.9.2" 389 | description = "Yet another URL library" 390 | category = "main" 391 | optional = false 392 | python-versions = ">=3.7" 393 | 394 | [package.dependencies] 395 | idna = ">=2.0" 396 | multidict = ">=4.0" 397 | 398 | [metadata] 399 | lock-version = "1.1" 400 | python-versions = "^3.8.1" 401 | content-hash = "2432f960fd8053eefdb20a3bac128f2b5655d1757557501d178f790debc174c4" 402 | 403 | [metadata.files] 404 | aiohttp = [] 405 | aiosignal = [] 406 | async-timeout = [] 407 | atomicwrites = [] 408 | attrs = [] 409 | certifi = [] 410 | cffi = [] 411 | charset-normalizer = [] 412 | colorama = [] 413 | cryptography = [] 414 | flake8 = [] 415 | frozenlist = [] 416 | idna = [] 417 | iniconfig = [] 418 | instagrapi = [] 419 | mccabe = [] 420 | multidict = [] 421 | packaging = [] 422 | pillow = [] 423 | pluggy = [] 424 | py = [] 425 | pycodestyle = [] 426 | pycparser = [] 427 | pycryptodomex = [] 428 | pydantic = [] 429 | pyflakes = [] 430 | pysocks = [] 431 | pytest = [] 432 | pytest-asyncio = [] 433 | pytest-mock = [] 434 | pytz = [] 435 | requests = [] 436 | toml = [] 437 | typing-extensions = [] 438 | urllib3 = [] 439 | yarl = [] 440 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "threads-api" 3 | version = "1.2.0" 4 | description = "Unofficial Python client for Meta Threads.net API" 5 | authors = ["Danie1"] 6 | readme = "README.md" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.8.1" 10 | requests = "^2.26.0" 11 | pytest = "^6.2.4" 12 | pytest-asyncio = "^0.15.1" 13 | aiohttp = "^3.8.4" 14 | cryptography = "^41.0.2" 15 | instagrapi = "^1.17.13" 16 | pillow = "^10.0.0" 17 | colorama = "^0.4.6" 18 | pytest-mock = "^3.11.1" 19 | flake8 = "^6.0.0" 20 | pytz = "^2023.3" 21 | 22 | [build-system] 23 | requires = ["poetry-core>=1.0.0"] 24 | build-backend = "poetry.core.masonry.api" 25 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests >= 2.26.0 2 | pytest >= 6.2.4 3 | pytest-asyncio >= 0.15.1 4 | aiohttp >= 3.8.4 5 | cryptography >= 41.0.2 6 | instagrapi >= 1.17.13 7 | pillow >= 10.0.0 8 | colorama >= 0.4.6 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | with open('README.md', 'r', encoding='utf-8') as f: 4 | long_description = f.read() 5 | 6 | setup( 7 | name='threads-api', 8 | version='1.2.0', 9 | description='Unofficial Python client for Meta Threads.net API', 10 | long_description=long_description, 11 | long_description_content_type='text/markdown', 12 | author='Daniel Saad', 13 | author_email='danielsaad777@gmail.com', 14 | url='https://github.com/danie1/threads-api', 15 | packages=find_packages(), 16 | install_requires=[ 17 | 'aiohttp' 18 | ], 19 | classifiers=[ 20 | 'Operating System :: OS Independent', 21 | 'Intended Audience :: Developers', 22 | 'License :: OSI Approved :: MIT License', 23 | 24 | 'Programming Language :: Python :: 3.9', 25 | 'Programming Language :: Python :: 3.10', 26 | 'Programming Language :: Python :: 3.11', 27 | ], 28 | ) -------------------------------------------------------------------------------- /threads_api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Danie1/threads-api/00ce459eff46c6109928b9ef9734eba91f499fd0/threads_api/__init__.py -------------------------------------------------------------------------------- /threads_api/src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Danie1/threads-api/00ce459eff46c6109928b9ef9734eba91f499fd0/threads_api/src/__init__.py -------------------------------------------------------------------------------- /threads_api/src/anotherlogger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from colorama import init, Fore, Style 3 | import json 4 | 5 | def is_json_serializable(obj): 6 | try: 7 | json.dumps(obj) 8 | return True 9 | except (TypeError, ValueError): 10 | return False 11 | 12 | def format_log(*args, **kwargs): 13 | log_message = f"{Fore.GREEN}<---- START ---->\n" 14 | 15 | # Collect positional arguments 16 | if args: 17 | log_message += "Positional arguments:\n" 18 | for index, arg in enumerate(args, start=1): 19 | log_message += f" Arg {index}: [{Style.RESET_ALL}{arg}{Fore.GREEN}]\n" 20 | 21 | # Collect keyword arguments 22 | if kwargs: 23 | log_message += "Keyword arguments:\n" 24 | for key, value in kwargs.items(): 25 | if is_json_serializable(value): 26 | value = json.dumps(value, indent=4) 27 | log_message += f" [{key}]: [{Style.RESET_ALL}{value}{Fore.GREEN}]\n" 28 | 29 | log_message += f"<---- END ---->\n{Style.RESET_ALL}" 30 | return log_message 31 | 32 | def log_info(*args, **kwargs): 33 | # Log the message 34 | logging.info(format_log(*args, **kwargs)) 35 | 36 | def log_debug(*args, **kwargs): 37 | # Log the message 38 | logging.debug(format_log(*args, **kwargs)) -------------------------------------------------------------------------------- /threads_api/src/http_sessions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Danie1/threads-api/00ce459eff46c6109928b9ef9734eba91f499fd0/threads_api/src/http_sessions/__init__.py -------------------------------------------------------------------------------- /threads_api/src/http_sessions/abstract_session.py: -------------------------------------------------------------------------------- 1 | class HTTPSession: 2 | async def start(self): 3 | raise NotImplementedError 4 | 5 | def auth(self, auth_callback_func, **kwargs): 6 | raise NotImplementedError 7 | 8 | async def close(self): 9 | raise NotImplementedError 10 | 11 | async def post(self, **kwargs): 12 | raise NotImplementedError 13 | 14 | async def get(self, **kwargs): 15 | raise NotImplementedError 16 | 17 | async def download(self, **kwargs): 18 | raise NotImplementedError -------------------------------------------------------------------------------- /threads_api/src/http_sessions/aiohttp_session.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | import json 3 | 4 | from threads_api.src.http_sessions.abstract_session import HTTPSession 5 | 6 | from instagrapi import Client 7 | 8 | class AioHTTPSession(HTTPSession): 9 | def __init__(self): 10 | self._session = aiohttp.ClientSession() 11 | self._instagrapi_client = Client() 12 | 13 | async def start(self): 14 | if self._session is None: 15 | self._session = aiohttp.ClientSession() 16 | 17 | if self._instagrapi_client is None: 18 | self._instagrapi_client = Client() 19 | 20 | async def close(self): 21 | await self._session.close() 22 | self._session = None 23 | self._instagrapi_client = None 24 | 25 | def auth(self, **kwargs): 26 | self._instagrapi_client.login(**kwargs) 27 | token = self._instagrapi_client.private.headers['Authorization'].split("Bearer IGT:2:")[1] 28 | return token 29 | 30 | async def download(self, **kwargs): 31 | async with self._session.get(**kwargs) as response: 32 | return await response.read() 33 | 34 | async def post(self, **kwargs): 35 | async with self._session.post(**kwargs) as response: 36 | return await response.text() 37 | 38 | async def get(self, **kwargs): 39 | async with self._session.get(**kwargs) as response: 40 | return await response.text() 41 | 42 | 43 | -------------------------------------------------------------------------------- /threads_api/src/http_sessions/instagrapi_session.py: -------------------------------------------------------------------------------- 1 | from instagrapi import Client 2 | import json 3 | import requests 4 | 5 | from threads_api.src.http_sessions.abstract_session import HTTPSession 6 | from threads_api.src.anotherlogger import log_debug 7 | 8 | class InstagrapiSession(HTTPSession): 9 | def __init__(self): 10 | self._instagrapi_client = Client() 11 | self._instagrapi_headers = self._instagrapi_client.private.headers 12 | self._threads_headers = { 13 | 'User-Agent': 'Barcelona 289.0.0.77.109 Android', 14 | 'Sec-Fetch-Site': 'same-origin', 15 | 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 16 | } 17 | 18 | # override with Threads headers 19 | self._instagrapi_client.private.headers=self._threads_headers 20 | 21 | def auth(self, **kwargs): 22 | # restore original headers for Instagram login 23 | self._instagrapi_client.private.headers = self._instagrapi_headers 24 | self._instagrapi_client.login(**kwargs) 25 | token = self._instagrapi_client.private.headers['Authorization'].split("Bearer IGT:2:")[1] 26 | 27 | # override with Threads headers 28 | self._instagrapi_client.private.headers = self._threads_headers 29 | 30 | return token 31 | 32 | async def start(self): 33 | pass 34 | 35 | async def close(self): 36 | pass 37 | 38 | async def post(self, **kwargs): 39 | response = self._instagrapi_client.private.post(**kwargs) 40 | return response.text 41 | 42 | async def get(self, **kwargs): 43 | response = self._instagrapi_client.private.get(**kwargs) 44 | return response.text 45 | 46 | async def download(self, **kwargs): 47 | response = self._instagrapi_client.private.get(**kwargs, stream=True) 48 | response.raw.decode_content = True 49 | return response.content -------------------------------------------------------------------------------- /threads_api/src/http_sessions/requests_session.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import json 3 | 4 | from threads_api.src.http_sessions.abstract_session import HTTPSession 5 | from threads_api.src.anotherlogger import log_debug 6 | 7 | from instagrapi import Client 8 | 9 | class RequestsSession(HTTPSession): 10 | def __init__(self): 11 | self._session = requests.Session() 12 | self._instagrapi_client = Client() 13 | 14 | async def start(self): 15 | if self._session is None: 16 | self._session = requests.Session() 17 | 18 | async def close(self): 19 | self._session.close() 20 | self._session = None 21 | 22 | def auth(self, **kwargs): 23 | self._instagrapi_client.login(**kwargs) 24 | token = self._instagrapi_client.private.headers['Authorization'].split("Bearer IGT:2:")[1] 25 | return token 26 | 27 | async def post(self, **kwargs): 28 | response = self._session.post(**kwargs) 29 | return response.text 30 | 31 | async def get(self, **kwargs): 32 | response = self._session.get(**kwargs) 33 | return response.text 34 | 35 | async def download(self, **kwargs): 36 | response = self._session.get(**kwargs, stream=True) 37 | response.raw.decode_content = True 38 | return response.content -------------------------------------------------------------------------------- /threads_api/src/settings.py: -------------------------------------------------------------------------------- 1 | import json 2 | import hashlib 3 | import time 4 | from datetime import datetime 5 | 6 | class Settings: 7 | 8 | def __init__(self): 9 | """ 10 | Construct the object. 11 | 12 | Arguments: 13 | settings: (dict/str): a settings dictionary or a path to a JSON file. 14 | """ 15 | self.encrypted_token = None 16 | self.timezone_offset = "-" + str((datetime.now() - datetime.utcnow()).seconds) 17 | self.device_id = self.generate_android_device_id() 18 | self.device_manufacturer = 'OnePlus' 19 | self.device_model = 'ONEPLUS+A3010' 20 | self.device_android_version = 25 21 | self.device_android_release = '7.1.1' 22 | 23 | def set_encrypted_token(self, encrypted_token): 24 | self.encrypted_token = encrypted_token 25 | 26 | def load_settings(self, path): 27 | """ 28 | Load session settings 29 | 30 | Parameters 31 | ---------- 32 | path: Path 33 | Path to storage file 34 | 35 | Returns 36 | ------- 37 | Dict 38 | Current session settings as a Dict 39 | """ 40 | with open(path, "r") as fp: 41 | self.set_settings(json.load(fp)) 42 | return self.get_settings() 43 | return None 44 | 45 | def dump_settings(self, path): 46 | """ 47 | Serialize and save session settings 48 | 49 | Parameters 50 | ---------- 51 | path: Path 52 | Path to storage file 53 | 54 | Returns 55 | ------- 56 | Bool 57 | """ 58 | with open(path, "w") as fp: 59 | fp.write(json.dumps(self.get_settings(), indent=4)) 60 | return True 61 | 62 | def get_settings(self): 63 | """ 64 | Get current session settings 65 | 66 | Returns 67 | ------- 68 | Dict 69 | Current session settings as a Dict 70 | """ 71 | return { 72 | 'authentication': { 73 | 'encrypted_token': self.encrypted_token, 74 | }, 75 | 'timezone': { 76 | 'offset': self.timezone_offset, 77 | }, 78 | 'device': { 79 | 'id': self.device_id, 80 | 'manufacturer': self.device_manufacturer, 81 | 'model': self.device_model, 82 | 'android_version': self.device_android_version, 83 | 'android_release': self.device_android_release, 84 | }, 85 | } 86 | 87 | 88 | def set_settings(self, settings): 89 | if settings is None: 90 | raise Exception("Provide valid settings to set") 91 | 92 | self.encrypted_token = settings.get('authentication').get('encrypted_token') 93 | self.timezone_offset = settings.get('timezone').get('offset') 94 | self.device_id = settings.get('device').get('id') 95 | self.device_manufacturer = settings.get('device').get('manufacturer') 96 | self.device_model = settings.get('device').get('model') 97 | self.device_android_version = settings.get('device').get('android_version') 98 | self.device_android_release = settings.get('device').get('android_release') 99 | 100 | @property 101 | def device_as_dict(self) -> dict: 102 | """ 103 | Get a device information. 104 | 105 | Returns: 106 | The device information as a dict. 107 | """ 108 | return { 109 | 'manufacturer': self.device_manufacturer, 110 | 'model': self.device_model, 111 | 'android_version': self.device_android_version, 112 | 'android_release': self.device_android_release, 113 | } 114 | 115 | def generate_android_device_id(self): 116 | """ 117 | Helper to generate Android Device ID 118 | 119 | Returns 120 | ------- 121 | str 122 | A random android device id 123 | """ 124 | return "android-%s" % hashlib.sha256(str(time.time()).encode()).hexdigest()[:16] -------------------------------------------------------------------------------- /threads_api/src/threads_api.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Dict, Union, List 2 | import aiohttp 3 | import re 4 | import json 5 | import asyncio 6 | import json 7 | import random 8 | from datetime import datetime 9 | import urllib.parse 10 | import random 11 | import urllib 12 | import os 13 | import mimetypes 14 | import uuid 15 | import time 16 | import copy 17 | 18 | import secrets 19 | from base64 import urlsafe_b64encode as b64e, urlsafe_b64decode as b64d 20 | 21 | from cryptography.fernet import Fernet 22 | from cryptography.hazmat.backends import default_backend 23 | from cryptography.hazmat.primitives import hashes 24 | from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC 25 | 26 | import logging 27 | import sys 28 | from threads_api.src.anotherlogger import format_log 29 | from threads_api.src.anotherlogger import log_debug 30 | from colorama import init, Fore, Style 31 | import functools 32 | from threads_api.src.settings import Settings 33 | 34 | from threads_api.src.http_sessions.aiohttp_session import AioHTTPSession 35 | from threads_api.src.types import * 36 | 37 | OPEN_ISSUE_MESSAGE = f"{Fore.RED}Oops, this is an error that hasn't yet been properly handled.\nPlease open an issue on Github at https://github.com/Danie1/threads-api.{Style.RESET_ALL}" 38 | 39 | BASE_URL = "https://i.instagram.com/api/v1" 40 | LOGIN_URL = BASE_URL + "/bloks/apps/com.bloks.www.bloks.caa.login.async.send_login_request/" 41 | POST_URL_TEXTONLY = BASE_URL + "/media/configure_text_only_post/" 42 | POST_URL_IMAGE = BASE_URL + "/media/configure_text_post_app_feed/" 43 | POST_URL_SIDECAR = BASE_URL + "/media/configure_text_post_app_sidecar/" 44 | DEFAULT_HEADERS = { 45 | 'Authority': 'www.threads.net', 46 | 'Accept': '*/*', 47 | 'Accept-Language': 'en-US,en;q=0.9', 48 | 'Cache-Control': 'no-cache', 49 | 'Content-Type': 'application/x-www-form-urlencoded', 50 | 'Origin': 'https://www.threads.net', 51 | 'Pragma': 'no-cache', 52 | 'Sec-Fetch-Dest': 'document', 53 | 'Sec-Fetch-Mode': 'navigate', 54 | 'Sec-Fetch-Site': 'cross-site', 55 | 'Sec-Fetch-User': '?1', 56 | 'Upgrade-Insecure-Requests': '1', 57 | 'User-Agent': ( 58 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) ' 59 | 'AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.1.2 Safari/605.1.15' 60 | ), 61 | 'X-ASBD-ID': '129477', 62 | 'X-FB-LSD': 'NjppQDEgONsU_1LCzrmp6q', 63 | 'X-IG-App-ID': '238260118697367', 64 | } 65 | 66 | class SimpleEncDec: 67 | backend = default_backend() 68 | iterations = 100_000 69 | 70 | @staticmethod 71 | def _derive_key(password: bytes, salt: bytes, iterations: int = iterations) -> bytes: 72 | """Derive a secret key from a given password and salt""" 73 | kdf = PBKDF2HMAC( 74 | algorithm=hashes.SHA256(), length=32, salt=salt, 75 | iterations=iterations, backend=SimpleEncDec.backend) 76 | return b64e(kdf.derive(password)) 77 | 78 | @staticmethod 79 | def password_encrypt(message: bytes, password: str, iterations: int = iterations) -> bytes: 80 | salt = secrets.token_bytes(16) 81 | key = SimpleEncDec._derive_key(password.encode(), salt, iterations) 82 | return b64e( 83 | b'%b%b%b' % ( 84 | salt, 85 | iterations.to_bytes(4, 'big'), 86 | b64d(Fernet(key).encrypt(message)), 87 | ) 88 | ) 89 | 90 | @staticmethod 91 | def password_decrypt(token: bytes, password: str) -> bytes: 92 | decoded = b64d(token) 93 | salt, iter, token = decoded[:16], decoded[16:20], b64e(decoded[20:]) 94 | iterations = int.from_bytes(iter, 'big') 95 | key = SimpleEncDec._derive_key(password.encode(), salt, iterations) 96 | return Fernet(key).decrypt(token) 97 | 98 | class LoggedOutException(Exception): 99 | def __init__(self, message): 100 | # Call the base class constructor with the parameters it needs 101 | super().__init__(message) 102 | 103 | def require_login(func): 104 | @functools.wraps(func) 105 | async def wrapper(self, *args, **kwargs): 106 | if not self.is_logged_in: 107 | raise Exception(f"The action '{func.__name__}' can only be perfomed while logged-in") 108 | return await func(self, *args, **kwargs) 109 | return wrapper 110 | 111 | class ThreadsAPI: 112 | def __init__(self, http_session_class=AioHTTPSession, settings_path : str=".session.json"): 113 | 114 | # Get the log level from the environment variable 115 | log_level_env = os.environ.get("LOG_LEVEL", "WARNING") 116 | 117 | # Set the log level based on the environment variable 118 | log_level = getattr(logging, log_level_env.upper(), None) 119 | if not isinstance(log_level, int): 120 | raise ValueError(f"Invalid log level: {log_level_env}") 121 | 122 | self.log_level = log_level 123 | 124 | self.set_log_level(self.log_level) 125 | 126 | self.logger = logging.getLogger(name="ThreadsAPILogger") 127 | 128 | 129 | # Setup settings 130 | self.settings_path = settings_path 131 | self.settings = Settings() 132 | 133 | # Check if settings_path is configured 134 | if settings_path is not None: 135 | if os.path.exists(settings_path): 136 | self.settings.load_settings(settings_path) 137 | else: 138 | # Create settings file on filesystem if it doesn't exist already 139 | self.settings.dump_settings(settings_path) 140 | 141 | # Set the HTTP client class to use when initializing sessions 142 | self.http_session_class = http_session_class 143 | 144 | # Setup public connection members 145 | self._public_session = self.http_session_class() 146 | 147 | # Setup private connection members 148 | self._auth_session = self.http_session_class() 149 | self.token = None 150 | self.user_id = None 151 | self.is_logged_in = False 152 | self.auth_headers = None 153 | 154 | self.FBLSDToken = 'NjppQDEgONsU_1LCzrmp6q' 155 | 156 | # Log all configureable attributes for troubleshooting 157 | self.logger.info(format_log(message="ThreadsAPI.__init__ Configurations", 158 | settings_path=self.settings_path, 159 | log_level=self.log_level, 160 | http_session_class=self.http_session_class.__name__, 161 | settings=self.settings.get_settings())) 162 | 163 | def set_log_level(self, log_level): 164 | self.log_level = log_level 165 | logging.basicConfig(level=self.log_level, format='%(levelname)s:%(message)s') 166 | 167 | def _extract_response_json(self, response): 168 | try: 169 | resp = json.loads(response) 170 | except (aiohttp.ContentTypeError, json.JSONDecodeError): 171 | if response.find("not-logged-in") > 0: 172 | raise Exception(f"You're trying to perform an operation without permission. Check: Are you logged into the correct user? Can you perform the action on the input provided?") 173 | else: 174 | raise Exception(f'Failed to decode response [{response}] as JSON.\n\n{OPEN_ISSUE_MESSAGE}') 175 | 176 | return resp 177 | 178 | @require_login 179 | async def _private_post(self, **kwargs): 180 | log_debug(title='PRIVATE REQUEST', type='POST', **kwargs) 181 | response = await self._auth_session.post(**kwargs) 182 | resp_json = self._extract_response_json(response) 183 | log_debug(title='PRIVATE RESPONSE', response=resp_json) 184 | 185 | if 'status' in resp_json and resp_json['status'] == 'fail' or \ 186 | 'errors' in resp_json: 187 | raise Exception(f"Request Failed, got back: [{resp_json}]\n{OPEN_ISSUE_MESSAGE}") 188 | 189 | return resp_json 190 | 191 | 192 | @require_login 193 | async def _private_get(self, **kwargs): 194 | log_debug(title='PRIVATE REQUEST', type='GET', **kwargs) 195 | response = await self._auth_session.get(**kwargs) 196 | resp_json = self._extract_response_json(response) 197 | log_debug(title='PRIVATE RESPONSE', response=resp_json) 198 | 199 | if 'status' in resp_json and resp_json['status'] == 'fail' or \ 200 | 'errors' in resp_json: 201 | if 'message' in resp_json and resp_json['message'] == 'challenge_required': 202 | raise LoggedOutException("It appears you have been logged out.") 203 | raise Exception(f"Request Failed, got back: [{resp_json}]\n{OPEN_ISSUE_MESSAGE}") 204 | 205 | return resp_json 206 | 207 | 208 | async def _public_post_json(self, **kwargs): 209 | log_debug(title='PUBLIC REQUEST', type='POST', **kwargs) 210 | response = await self._public_session.post(**kwargs) 211 | resp_json = self._extract_response_json(response) 212 | log_debug(title='PUBLIC RESPONSE', response=resp_json) 213 | 214 | if 'status' in resp_json and resp_json['status'] == 'fail' or \ 215 | 'errors' in resp_json: 216 | raise Exception(f"Request Failed, got back: [{resp_json}]\n{OPEN_ISSUE_MESSAGE}") 217 | 218 | return resp_json 219 | 220 | async def _public_get_json(self, **kwargs): 221 | log_debug(title='PUBLIC REQUEST', type='GET', **kwargs) 222 | response = await self._public_session.get(**kwargs) 223 | resp_json = self._extract_response_json(response) 224 | log_debug(title='PUBLIC RESPONSE', response=resp_json) 225 | 226 | if 'status' in resp_json and resp_json['status'] == 'fail' or \ 227 | 'errors' in resp_json: 228 | raise Exception(f"Request Failed, got back: [{resp_json}]\n{OPEN_ISSUE_MESSAGE}") 229 | 230 | return resp_json 231 | 232 | async def _public_post_text(self, **kwargs): 233 | log_debug(title='PUBLIC REQUEST', type='POST', **kwargs) 234 | response = await self._public_session.post(**kwargs) 235 | log_debug(title='PUBLIC RESPONSE', response=response) 236 | 237 | return response 238 | 239 | async def _public_get_text(self, **kwargs): 240 | log_debug(title='PUBLIC REQUEST', type='GET', **kwargs) 241 | response = await self._public_session.get(**kwargs) 242 | log_debug(title='PUBLIC RESPONSE', response=response) 243 | 244 | return response 245 | 246 | async def load_settings(self, path: str = None): 247 | return self.settings.load_settings(path) 248 | 249 | async def dump_settings(self, path: str = None): 250 | return self.settings.dump_settings(path) 251 | 252 | async def _get_public_headers(self) -> str: 253 | default_headers = copy.deepcopy(DEFAULT_HEADERS) 254 | default_headers['X-FB-LSD'] = await self._refresh_public_token() 255 | return default_headers 256 | 257 | async def _refresh_public_token(self) -> str: 258 | self.logger.info("Refreshing public token") 259 | modified_default_headers = copy.deepcopy(DEFAULT_HEADERS) 260 | del modified_default_headers['X-FB-LSD'] 261 | url = 'https://www.instagram.com/instagram' 262 | 263 | data = await self._public_get_text(url=url, headers=modified_default_headers) 264 | 265 | token_key_value = re.search('LSD",\\[\\],{"token":"(.*?)"},\\d+\\]', data).group() 266 | token_key_value = token_key_value.replace('LSD",[],{"token":"', '') 267 | token = token_key_value.split('"')[0] 268 | 269 | self.FBLSDToken = token 270 | return self.FBLSDToken 271 | 272 | async def login(self, username, password, cached_token_path=None): 273 | """ 274 | Logs in the user with the provided username and password. 275 | 276 | Args: 277 | username (str): The username for authentication. 278 | password (str): The password for authentication. 279 | 280 | Returns: 281 | bool: True if the login is successful, False otherwise. 282 | 283 | Raises: 284 | Exception: If the username or password are invalid, or if an error occurs during login. 285 | """ 286 | def _save_token_to_cache(cached_token_path, token, password): 287 | with open(cached_token_path, "wb") as fd: 288 | encrypted_token = SimpleEncDec.password_encrypt(token.encode(), password) 289 | fd.write(encrypted_token) 290 | self.logger.info("Saved token encrypted to cache") 291 | 292 | # Sync token between original token cache and the settings file 293 | self.settings.set_encrypted_token(encrypted_token.decode()) 294 | 295 | if self.settings_path is not None: 296 | self.settings.dump_settings(self.settings_path) 297 | self.logger.info("Saved token encrypted to settings file") 298 | return 299 | 300 | def _get_token_from_cache(cached_token_path, password): 301 | decrypted_token = None 302 | 303 | # Try to load the token from the settings file before looking in the original token cache 304 | if self.settings_path is not None: 305 | try: 306 | self.settings.load_settings(self.settings_path) 307 | 308 | if self.settings.encrypted_token is not None: 309 | decrypted_token = SimpleEncDec.password_decrypt(self.settings.encrypted_token.encode(), password).decode() 310 | self.logger.info("Loaded encrypted token from settings file") 311 | except FileNotFoundError: 312 | # Use default settings if the file does not exist yet 313 | self.logger.info(f"Using default settings because no file was found in {self.settings_path}") 314 | pass 315 | 316 | # stop using cached_token_path if encrypted_token exists in settings file 317 | if decrypted_token is None: 318 | with open(cached_token_path, "rb") as fd: 319 | encrypted_token = fd.read() 320 | 321 | # Sync token between original token cache and the settings file 322 | self.settings.set_encrypted_token(encrypted_token.decode()) 323 | self.settings.dump_settings(self.settings_path) 324 | 325 | decrypted_token = SimpleEncDec.password_decrypt(encrypted_token, password).decode() 326 | self.logger.info("Loaded encrypted token from cache") 327 | return decrypted_token 328 | 329 | async def _set_logged_in_state(username, token): 330 | self.token = token 331 | self.auth_headers = { 332 | 'Authorization': f'Bearer IGT:2:{self.token}', 333 | 'User-Agent': 'Barcelona 289.0.0.77.109 Android', 334 | 'Sec-Fetch-Site': 'same-origin', 335 | 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 336 | } 337 | self.is_logged_in = True 338 | self.user_id = await self.get_user_id_from_username(username) 339 | self.logger.info("Set logged-in state successfully. All set!") 340 | return 341 | 342 | if username is None or password is None: 343 | raise Exception("Username or password are invalid") 344 | 345 | self.username = username 346 | 347 | # Look in cache before logging in. 348 | if cached_token_path is not None and os.path.exists(cached_token_path): 349 | self.logger.info(f"Found cache file in {cached_token_path}, attempting to read the token from it.") 350 | try: 351 | await _set_logged_in_state(username, _get_token_from_cache(cached_token_path, password)) 352 | return True 353 | except LoggedOutException as e: 354 | print(f"[Error] {e}. Attempting to re-login.") 355 | pass 356 | 357 | try: 358 | self.logger.info("Attempting to login") 359 | token = self._auth_session.auth(username=username, password=password) 360 | 361 | await _set_logged_in_state(username, token) 362 | 363 | if cached_token_path is not None: 364 | _save_token_to_cache(cached_token_path, token, password) 365 | except Exception as e: 366 | print("[ERROR] ", e) 367 | raise 368 | 369 | return True 370 | 371 | async def close_gracefully(self): 372 | if self._auth_session is not None: 373 | await self._auth_session.close() 374 | self._auth_session = None 375 | 376 | if self._public_session is not None: 377 | await self._public_session.close() 378 | self._public_session = None 379 | 380 | self.user_id = None 381 | self.is_logged_in = False 382 | self.token = None 383 | 384 | async def get_user_id_from_username(self, username: str) -> str: 385 | """ 386 | Retrieves the user ID associated with a given username. 387 | 388 | Args: 389 | username (str): The username to retrieve the user ID for. 390 | 391 | Returns: 392 | str: The user ID if found, or None if the user ID is not found. 393 | """ 394 | if self.is_logged_in and self.username == username: 395 | self.logger.info(f"Fetching user_id for user [{username}] while logged-in") 396 | 397 | data = await self._private_get(url=f"{BASE_URL}/users/{username}/usernameinfo/", headers=self.auth_headers) 398 | 399 | if 'message' in data and data['message'] == "login_required" or \ 400 | 'status' in data and data['status'] == 'fail': 401 | if 'User not onboarded' in data['message']: 402 | raise Exception(f"User {username} is not onboarded to threads.net") 403 | elif 'challenge_required' in data['message'] and \ 404 | 'challenge' in data and 'url' in data['challenge'] and \ 405 | 'https://www.instagram.com/accounts/suspended/' in data['challenge']['url']: 406 | raise Exception(f"User {username} is suspended from threads.net :(") 407 | 408 | # Cross fingers you reach this exception and not the previous ones 409 | raise LoggedOutException(str(data)) 410 | 411 | user_id = int(data['user']['pk']) 412 | return user_id 413 | else: 414 | self.logger.info(f"Fetching user_id for user [{username}] anonymously") 415 | 416 | headers = await self._get_public_headers() 417 | text = await self._public_get_text(url=f"https://www.threads.net/@{username}", headers=headers) 418 | 419 | text = text.replace('\\s', "").replace('\\n', "") 420 | user_id = re.search(r'"props":{"user_id":"(\d+)"},', text) 421 | 422 | return user_id.group(1) if user_id else None 423 | 424 | async def get_user_profile(self, user_id: str) -> User: 425 | """ 426 | Retrieves the profile information for a user with the provided user ID. 427 | 428 | Args: 429 | user_id (str): The user ID for which to retrieve the profile information. 430 | 431 | Returns: 432 | dict: A dictionary containing the user profile information. 433 | 434 | Raises: 435 | Exception: If an error occurs during the profile retrieval process. 436 | """ 437 | url = 'https://www.threads.net/api/graphql' 438 | 439 | modified_headers = copy.deepcopy(await self._get_public_headers()) 440 | 441 | modified_headers.update({ 442 | 'sec-fetch-dest': 'empty', 443 | 'sec-fetch-mode': 'cors', 444 | 'sec-fetch-site': 'same-origin', 445 | 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36', 446 | 'x-fb-friendly-name': 'BarcelonaProfileRootQuery', 447 | 'x-fb-lsd': self.FBLSDToken, 448 | }) 449 | 450 | payload = { 451 | 'lsd': self.FBLSDToken, 452 | 'variables': json.dumps( 453 | { 454 | 'userID': user_id, 455 | } 456 | ), 457 | 'doc_id': '23996318473300828' 458 | } 459 | 460 | data = await self._public_post_json(url=url, headers=modified_headers, data=payload) 461 | 462 | return User(**data['data']['userData']['user']) 463 | 464 | async def get_user_threads(self, user_id: str, count=10, max_id=None) -> Threads: 465 | """ 466 | Retrieves the threads associated with a user with the provided user ID. 467 | 468 | Args: 469 | user_id (str): The user ID for which to retrieve the threads. 470 | 471 | Returns: 472 | list: A list of dictionaries representing the threads associated with the user. 473 | 474 | Raises: 475 | Exception: If an error occurs during the thread retrieval process. 476 | """ 477 | if self.is_logged_in: 478 | if max_id is not None: 479 | params = {'count': count, 'max_id':max_id} 480 | else: 481 | params = {'count': count} 482 | resp = await self._private_get(url=f'{BASE_URL}/text_feed/{user_id}/profile/', headers=self.auth_headers,data=params) 483 | 484 | return Threads(**resp) 485 | # Public API for getting user threads is minimal. 'count' and 'max_id' are not used. 486 | else: 487 | url = 'https://www.threads.net/api/graphql' 488 | 489 | modified_headers = copy.deepcopy(await self._get_public_headers()) 490 | 491 | modified_headers.update({ 492 | 'sec-fetch-dest': 'empty', 493 | 'sec-fetch-mode': 'cors', 494 | 'sec-fetch-site': 'same-origin', 495 | 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36', 496 | 'x-fb-friendly-name': 'BarcelonaProfileThreadsTabQuery', 497 | 'x-fb-lsd': self.FBLSDToken, 498 | }) 499 | 500 | payload = { 501 | 'lsd': self.FBLSDToken, 502 | 'variables': json.dumps( 503 | { 504 | 'userID': user_id, 505 | } 506 | ), 507 | 'doc_id': '6232751443445612' 508 | } 509 | 510 | data = await self._public_post_json(url=url, headers=modified_headers, data=payload) 511 | 512 | threads = Threads(**data['data']['mediaData']) 513 | return threads 514 | 515 | async def get_user_replies(self, user_id: str, count=10, max_id : str = None) -> Threads: 516 | """ 517 | Retrieves the replies associated with a user with the provided user ID. 518 | 519 | Args: 520 | user_id (str): The user ID for which to retrieve the replies. 521 | 522 | Returns: 523 | list: A list of dictionaries representing the replies associated with the user. 524 | 525 | Raises: 526 | Exception: If an error occurs during the thread retrieval process. 527 | """ 528 | if self.is_logged_in: 529 | if max_id is not None: 530 | params = {'count': count, 'max_id':max_id} 531 | else: 532 | params = {'count': count} 533 | resp = await self._private_get(url=f'{BASE_URL}/text_feed/{user_id}/profile/replies', headers=self.auth_headers,data=params) 534 | 535 | return Threads(**resp) 536 | # Public API for getting user threads is minimal. 'count' and 'max_id' are not used. 537 | else: 538 | url = 'https://www.threads.net/api/graphql' 539 | 540 | modified_headers = copy.deepcopy(await self._get_public_headers()) 541 | 542 | modified_headers.update({ 543 | 'sec-fetch-dest': 'empty', 544 | 'sec-fetch-mode': 'cors', 545 | 'sec-fetch-site': 'same-origin', 546 | 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36', 547 | 'x-fb-friendly-name': 'BarcelonaProfileRepliesTabQuery', 548 | 'x-fb-lsd': self.FBLSDToken, 549 | }) 550 | 551 | payload = { 552 | 'lsd': self.FBLSDToken, 553 | 'variables': json.dumps( 554 | { 555 | 'userID': user_id, 556 | } 557 | ), 558 | 'doc_id': '6307072669391286' 559 | } 560 | 561 | data = await self._public_post_json(url=url, headers=modified_headers, data=payload) 562 | 563 | threads = Threads(**data['data']['mediaData']) 564 | return threads 565 | 566 | async def get_post_id_from_url(self, post_url : str): 567 | """ 568 | Retrieves the post ID from a given URL. 569 | 570 | Args: 571 | post_url (str): The URL of the post. 572 | Returns: 573 | str: The post ID if found, or None if the post ID is not found. 574 | 575 | Raises: 576 | Exception: If an error occurs during the post ID retrieval process. 577 | """ 578 | if "https://" in post_url and "/@" in post_url: 579 | raise Exception(f"Argument {post_url} is not a valid URL") 580 | elif "https://" in post_url and "/t" in post_url: 581 | shortcode = post_url.split("/t/")[-1].split("/")[0] 582 | elif len(post_url) == 11: 583 | shortcode = post_url 584 | alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" 585 | id = 0 586 | for char in shortcode: 587 | id = (id * 64) + alphabet.index(char) 588 | return str(id) 589 | 590 | async def get_post(self, post_id: str, count=10, max_id=None) -> Replies: 591 | """ 592 | Retrieves the post information for a given post ID. 593 | 594 | Args: 595 | post_id (str): The ID of the post. 596 | 597 | Returns: 598 | dict: A dictionary representing the post information. 599 | 600 | Raises: 601 | Exception: If an error occurs during the post retrieval process. 602 | """ 603 | 604 | if self.is_logged_in: 605 | if max_id is not None: 606 | params = {'count': count, 'max_id':max_id} 607 | else: 608 | params = {'count': count} 609 | data = await self._private_get(url=f'{BASE_URL}/text_feed/{post_id}/replies', headers=self.auth_headers, data=params) 610 | response = Replies(**data) 611 | else: 612 | url = 'https://www.threads.net/api/graphql' 613 | 614 | modified_headers = copy.deepcopy(await self._get_public_headers()) 615 | 616 | modified_headers.update({ 617 | 'sec-fetch-dest': 'empty', 618 | 'sec-fetch-mode': 'cors', 619 | 'sec-fetch-site': 'same-origin', 620 | 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36', 621 | 'x-fb-friendly-name': 'BarcelonaPostPageQuery', 622 | 'x-fb-lsd': self.FBLSDToken, 623 | }) 624 | 625 | payload = { 626 | 'lsd': self.FBLSDToken, 627 | 'variables': json.dumps( 628 | { 629 | 'postID': post_id, 630 | } 631 | ), 632 | 'doc_id': '5587632691339264', 633 | } 634 | 635 | data = await self._public_post_json(url=url, headers=modified_headers, data=payload) 636 | 637 | response = Replies(**data['data']['data']) 638 | return response 639 | 640 | async def get_post_likes(self, post_id:str) -> UsersList: 641 | """ 642 | Retrieves the likes for a post with the given post ID. 643 | 644 | Args: 645 | post_id (str): The ID of the post. 646 | 647 | Returns: 648 | list: A list of users who liked the post. 649 | 650 | Raises: 651 | Exception: If an error occurs during the post likes retrieval process. 652 | """ 653 | if self.is_logged_in: 654 | data = await self._private_get(url=f'{BASE_URL}/media/{post_id}_{self.user_id}/likers', headers=self.auth_headers) 655 | response = UsersList(**data) 656 | else: 657 | url = 'https://www.threads.net/api/graphql' 658 | 659 | modified_headers = copy.deepcopy(await self._get_public_headers()) 660 | 661 | modified_headers.update({ 662 | 'sec-fetch-dest': 'empty', 663 | 'sec-fetch-mode': 'cors', 664 | 'sec-fetch-site': 'same-origin', 665 | 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36', 666 | 'x-fb-friendly-name': 'BarcelonaPostPageQuery', 667 | 'x-fb-lsd': self.FBLSDToken, 668 | }) 669 | 670 | payload = { 671 | 'lsd': self.FBLSDToken, 672 | 'variables': json.dumps( 673 | { 674 | 'mediaID': post_id, 675 | } 676 | ), 677 | 'doc_id': '9360915773983802', 678 | } 679 | 680 | data = await self._public_post_json(url=url, headers=modified_headers, data=payload) 681 | 682 | response = UsersList(**data['data']['likers']) 683 | return response 684 | 685 | @require_login 686 | async def repost(self, post_id : str): 687 | """ 688 | Repost a post. 689 | 690 | Args: 691 | post_id (int): a post's identifier. 692 | 693 | Returns: 694 | The reposting information as a dict. 695 | """ 696 | return await self._private_post(url=f'{BASE_URL}/repost/create_repost/', headers=self.auth_headers, data=f'media_id={post_id}') 697 | 698 | @require_login 699 | async def delete_repost(self, post_id:str): 700 | """ 701 | Delete a repost. 702 | 703 | Args: 704 | post_id (int): a post's identifier. 705 | 706 | Returns: 707 | The unreposting information as a dict. 708 | """ 709 | return await self._private_post(url=f'{BASE_URL}/repost/delete_text_app_repost/', headers=self.auth_headers, data=f'original_media_id={post_id}') 710 | 711 | @require_login 712 | async def get_user_followers(self, user_id: str) -> bool: 713 | res = await self._private_post(f"{BASE_URL}/friendships/{user_id}/followers", headers=self.auth_headers) 714 | return res 715 | 716 | @require_login 717 | async def get_user_following(self, user_id: str) -> bool: 718 | res = await self._private_post(f"{BASE_URL}/friendships/{user_id}/following", headers=self.auth_headers) 719 | return res 720 | 721 | @require_login 722 | async def follow_user(self, user_id: str) -> bool: 723 | """ 724 | Follows a user with the given user ID. 725 | 726 | Args: 727 | user_id (str): The ID of the user to follow. 728 | 729 | Returns: 730 | bool: True if the user was followed successfully, False otherwise. 731 | 732 | Raises: 733 | Exception: If an error occurs during the follow process. 734 | """ 735 | res = await self._private_post(url=f"{BASE_URL}/friendships/create/{user_id}/", headers=self.auth_headers) 736 | return res["status"] == "ok" 737 | 738 | @require_login 739 | async def unfollow_user(self, user_id: str) -> bool: 740 | """ 741 | Unfollows a user with the given user ID. 742 | 743 | Args: 744 | user_id (str): The ID of the user to unfollow. 745 | 746 | Returns: 747 | bool: True if the user was unfollowed successfully, False otherwise. 748 | 749 | Raises: 750 | Exception: If an error occurs during the unfollow process. 751 | """ 752 | res = await self._private_post(url=f"{BASE_URL}/friendships/destroy/{user_id}/", headers=self.auth_headers) 753 | return res["status"] == "ok" 754 | 755 | @require_login 756 | async def like_post(self, post_id: str) -> bool: 757 | """ 758 | Likes a post with the given ID. 759 | 760 | Args: 761 | user_id (str): The ID of the post to like. 762 | 763 | Returns: 764 | bool: True if the post was liked successfully, False otherwise. 765 | 766 | Raises: 767 | Exception: If an error occurs during the like process. 768 | """ 769 | res = await self._private_post(url=f"{BASE_URL}/media/{post_id}_{self.user_id}/like/", headers=self.auth_headers) 770 | return res["status"] == "ok" 771 | 772 | @require_login 773 | async def unlike_post(self, post_id: str) -> bool: 774 | """ 775 | Unlikes a post with the given ID. 776 | 777 | Args: 778 | user_id (str): The ID of the post to unlike. 779 | 780 | Returns: 781 | bool: True if the post was unliked successfully, False otherwise. 782 | 783 | Raises: 784 | Exception: If an error occurs during the like process. 785 | """ 786 | res = await self._private_post(url=f"{BASE_URL}/media/{post_id}_{self.user_id}/unlike/", headers=self.auth_headers) 787 | return res["status"] == "ok" 788 | 789 | @require_login 790 | async def delete_post(self, post_id: str) -> bool: 791 | """ 792 | Deletes a post with the given ID. 793 | 794 | Args: 795 | user_id (str): The ID of the post to delete. 796 | 797 | Returns: 798 | bool: True if the post was deleted successfully, False otherwise. 799 | 800 | Raises: 801 | Exception: If an error occurs during the deletion process. 802 | """ 803 | res = await self._private_post(url=f"{BASE_URL}/media/{post_id}_{self.user_id}/delete/?media_type=TEXT_POST", headers=self.auth_headers) 804 | return res["status"] == "ok" 805 | 806 | @require_login 807 | async def get_timeline(self, maxID : str = None): 808 | """ 809 | Get timeline for the authenticated user 810 | 811 | Args: 812 | maxID (int): The ID token for the next batch of posts 813 | 814 | Returns: 815 | list: REST API JSON data response for the get_timeline request 816 | 817 | Raises: 818 | Exception: If an error occurs during the timeline retrieval process. 819 | """ 820 | # Check if you have the ID of the next batch to fetch 821 | if maxID is None: 822 | parameters = { 823 | 'pagination_source': 'text_post_feed_threads', 824 | } 825 | else: 826 | parameters = { 827 | 'pagination_source': 'text_post_feed_threads', 828 | 'max_id': maxID 829 | } 830 | 831 | res = await self._private_post(url=f'{BASE_URL}/feed/text_post_app_timeline/', headers=self.auth_headers,data=parameters) 832 | return TimelineData(**res) 833 | 834 | @require_login 835 | async def mute_user(self, user_id : str): 836 | """ 837 | Mute a user 838 | 839 | Args: 840 | user_id (int): The ID of the user to mute. 841 | 842 | Returns: 843 | dict: REST API Response in JSON format 844 | 845 | Raises: 846 | Exception: If an error occurs during the muting process. 847 | """ 848 | parameters = json.dumps( 849 | obj={ 850 | 'target_posts_author_id': user_id, 851 | 'container_module': 'ig_text_feed_timeline', 852 | }, 853 | ) 854 | 855 | encoded_parameters = urllib.parse.quote(string=parameters, safe="!~*'()") 856 | payload = f'signed_body=SIGNATURE.{encoded_parameters}' 857 | 858 | res = await self._private_post(url=f'{BASE_URL}/friendships/mute_posts_or_story_from_follow/', headers=self.auth_headers, data=payload) 859 | return res 860 | 861 | @require_login 862 | async def unmute_user(self, user_id : str): 863 | """ 864 | Unmute a user 865 | 866 | Args: 867 | user_id (int): The ID of the user to unmute. 868 | 869 | Returns: 870 | dict: REST API Response in JSON format 871 | 872 | Raises: 873 | Exception: If an error occurs during the unmuting process. 874 | """ 875 | parameters = json.dumps( 876 | obj={ 877 | 'target_posts_author_id': user_id, 878 | 'container_module': 'ig_text_feed_timeline', 879 | }, 880 | ) 881 | 882 | encoded_parameters = urllib.parse.quote(string=parameters, safe="!~*'()") 883 | payload = f'signed_body=SIGNATURE.{encoded_parameters}' 884 | 885 | res = await self._private_post(url=f'{BASE_URL}/friendships/unmute_posts_or_story_from_follow/', headers=self.auth_headers, data=payload) 886 | return res 887 | 888 | @require_login 889 | async def restrict_user(self, user_id : str): 890 | """ 891 | Restrict a user 892 | 893 | Args: 894 | user_id (int): The ID of the user to restrict. 895 | 896 | Returns: 897 | dict: REST API Response in JSON format 898 | 899 | Raises: 900 | Exception: If an error occurs during the restricting process. 901 | """ 902 | parameters = json.dumps( 903 | obj={ 904 | 'user_ids': user_id, 905 | 'container_module': 'ig_text_feed_timeline', 906 | }, 907 | ) 908 | 909 | encoded_parameters = urllib.parse.quote(string=parameters, safe="!~*'()") 910 | payload = f'signed_body=SIGNATURE.{encoded_parameters}' 911 | 912 | res = await self._private_post(url=f'{BASE_URL}/restrict_action/restrict_many/', headers=self.auth_headers, data=payload) 913 | return res 914 | 915 | @require_login 916 | async def unrestrict_user(self, user_id : str): 917 | """ 918 | Unrestrict a user 919 | 920 | Args: 921 | user_id (int): The ID of the user to unrestrict. 922 | 923 | Returns: 924 | dict: REST API Response in JSON format 925 | 926 | Raises: 927 | Exception: If an error occurs during the unrestricting process. 928 | """ 929 | parameters = json.dumps( 930 | obj={ 931 | 'user_ids': user_id, 932 | 'container_module': 'ig_text_feed_timeline', 933 | }, 934 | ) 935 | 936 | encoded_parameters = urllib.parse.quote(string=parameters, safe="!~*'()") 937 | payload = f'signed_body=SIGNATURE.{encoded_parameters}' 938 | res = await self._private_post(url=f'{BASE_URL}/restrict_action/unrestrict/', headers=self.auth_headers, data=payload) 939 | return res 940 | 941 | @require_login 942 | async def block_user(self, user_id : str): 943 | """ 944 | Block a user 945 | 946 | Args: 947 | user_id (int): The ID of the user to block. 948 | 949 | Returns: 950 | dict: REST API Response in JSON format 951 | 952 | Raises: 953 | Exception: If an error occurs during the blocking process. 954 | """ 955 | parameters = json.dumps( 956 | obj={ 957 | 'user_id': user_id, 958 | 'surface': 'ig_text_feed_timeline', 959 | 'is_auto_block_enabled': 'true', 960 | }, 961 | ) 962 | 963 | encoded_parameters = urllib.parse.quote(string=parameters, safe="!~*'()") 964 | payload = f'signed_body=SIGNATURE.{encoded_parameters}' 965 | res = await self._private_post(url=f'{BASE_URL}/friendships/block/{user_id}/', headers=self.auth_headers, data=payload) 966 | return res 967 | 968 | @require_login 969 | async def unblock_user(self, user_id : str): 970 | """ 971 | Unblock a user 972 | 973 | Args: 974 | user_id (int): The ID of the user to unblock. 975 | 976 | Returns: 977 | dict: REST API Response in JSON format 978 | 979 | Raises: 980 | Exception: If an error occurs during the unblocking process. 981 | """ 982 | parameters = json.dumps( 983 | obj={ 984 | 'user_id': user_id, 985 | 'container_module': 'ig_text_feed_timeline', 986 | }, 987 | ) 988 | 989 | encoded_parameters = urllib.parse.quote(string=parameters, safe="!~*'()") 990 | payload = f'signed_body=SIGNATURE.{encoded_parameters}' 991 | res = await self._private_post(url=f'{BASE_URL}/friendships/unblock/{user_id}/', headers=self.auth_headers, data=payload) 992 | return res 993 | 994 | @require_login 995 | async def search_user(self, query : str): 996 | """ 997 | Search for a user 998 | 999 | Args: 1000 | query (str): search query 1001 | 1002 | Returns: 1003 | dict: REST API Response in JSON format 1004 | 1005 | Raises: 1006 | Exception: If an error occurs during the search process. 1007 | """ 1008 | res = await self._private_get(url=f'{BASE_URL}/users/search/?q={query}', headers=self.auth_headers) 1009 | return res 1010 | 1011 | @require_login 1012 | async def get_recommended_users(self, max_id : str = None): 1013 | """ 1014 | Get list of recommended users 1015 | 1016 | Args: 1017 | max_id (str) : the next page of recommended users 1018 | #TODO This argument may not work. Still looking into this. Please open a Github Issue if you found a solution. 1019 | 1020 | Returns: 1021 | dict: REST API Response in JSON format 1022 | 1023 | Raises: 1024 | Exception: If an error occurs during the search process. 1025 | """ 1026 | max_id = f"?max_id={max_id}" if max_id else "" 1027 | res = await self._private_get(url=f'{BASE_URL}/text_feed/recommended_users/{max_id}', headers=self.auth_headers) 1028 | return res 1029 | 1030 | @require_login 1031 | async def get_notifications(self, selected_filter : str ="text_post_app_replies", max_id : str = None, pagination_first_record_timestamp:str = None): 1032 | """ 1033 | Get list of recommended users 1034 | 1035 | Args: 1036 | selected_filter (str): Choose one: "text_post_app_mentions", "text_post_app_replies", "verified" 1037 | 1038 | max_id (str) : the next page of get notifications 1039 | #TODO This argument may not work. Still looking into this. Please open a Github Issue if you found a solution. 1040 | 1041 | pagination_first_record_timestamp (str) : Timestamp of the first record for pagination 1042 | #TODO This argument may not work. Still looking into this. Please open a Github Issue if you found a solution. 1043 | 1044 | Returns: 1045 | dict: REST API Response in JSON format 1046 | 1047 | Raises: 1048 | Exception: If an error occurs during the search process. 1049 | """ 1050 | params = {'feed_type' : 'all', 1051 | 'mark_as_seen' : False, 1052 | 'timezone_offset':str(self.settings.timezone_offset) 1053 | } 1054 | 1055 | if selected_filter: 1056 | params.update({'selected_filter': selected_filter}) 1057 | 1058 | if max_id: 1059 | params.update({'max_id': max_id, 'pagination_first_record_timestamp': pagination_first_record_timestamp}) 1060 | 1061 | res = await self._private_get(url=f'{BASE_URL}/text_feed/text_app_notifications/', headers=self.auth_headers, data=params) 1062 | return res 1063 | 1064 | @require_login 1065 | async def post( 1066 | self, caption: str, image_path = None, url: str = None, parent_post_id: str = None, quoted_post_id: str = None) -> PostResponse: 1067 | """ 1068 | Creates a new post with the given caption, image, URL, and parent post ID. 1069 | 1070 | Args: 1071 | caption (str): The caption of the post. 1072 | image_path (str or list, optional: The path to the image file to be posted or list of images paths. Defaults to None. 1073 | url (str, optional): The URL to be attached to the post. Defaults to None. 1074 | parent_post_id (str, optional): The ID of the parent post if this post is a reply. Defaults to None. 1075 | 1076 | Returns: 1077 | bool: True if the post was created successfully, False otherwise. 1078 | 1079 | Raises: 1080 | Exception: If an error occurs during the post creation process. 1081 | """ 1082 | def __get_app_headers() -> dict: 1083 | headers = { 1084 | "User-Agent": f"Barcelona 289.0.0.77.109 Android", 1085 | "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", 1086 | } 1087 | if self.token is not None: 1088 | headers["Authorization"] = f"Bearer IGT:2:{self.token}" 1089 | return headers 1090 | 1091 | def generate_next_upload_id(): 1092 | return int(time.time() * 1000) 1093 | 1094 | async def _upload_image(path: str) -> dict: 1095 | random_number = random.randint(1000000000, 9999999999) 1096 | upload_id = generate_next_upload_id() 1097 | upload_name = f'{upload_id}_0_{random_number}' 1098 | 1099 | file_data = None 1100 | file_length = None 1101 | mime_type = 'image/jpeg' 1102 | waterfall_id = str(uuid.uuid4()) 1103 | 1104 | is_url = path.startswith('http') 1105 | is_file_path = not path.startswith('http') 1106 | 1107 | if is_file_path: 1108 | with open(path, 'rb') as file: 1109 | file_data = file.read() 1110 | file_length = len(file_data) 1111 | 1112 | mime_type = mimetypes.guess_type(path)[0] 1113 | 1114 | if is_url: 1115 | response = await self._public_session.download(url=path) 1116 | 1117 | file_data = response 1118 | file_length = len(response) 1119 | 1120 | if not is_file_path and not is_url: 1121 | raise ValueError('Provided image URL neither HTTP(S) URL nor file path. Please, create GitHub issue') 1122 | 1123 | if file_data is None and file_length is None: 1124 | raise ValueError('Provided image could not be uploaded. Please, create GitHub issue') 1125 | 1126 | parameters_as_string = { 1127 | 'media_type': 1, 1128 | 'upload_id': str(upload_id), 1129 | 'sticker_burnin_params': json.dumps([]), 1130 | 'image_compression': json.dumps( 1131 | { 1132 | 'lib_name': 'moz', 1133 | 'lib_version': '3.1.m', 1134 | 'quality': '80', 1135 | }, 1136 | ), 1137 | 'xsharing_user_ids': json.dumps([]), 1138 | 'retry_context': json.dumps( 1139 | { 1140 | 'num_step_auto_retry': '0', 1141 | 'num_reupload': '0', 1142 | 'num_step_manual_retry': '0', 1143 | }, 1144 | ), 1145 | 'IG-FB-Xpost-entry-point-v2': 'feed', 1146 | } 1147 | 1148 | headers = self.auth_headers | { 1149 | 'Accept-Encoding': 'gzip', 1150 | 'X-Instagram-Rupload-Params': json.dumps(parameters_as_string), 1151 | 'X_FB_PHOTO_WATERFALL_ID': waterfall_id, 1152 | 'X-Entity-Type': mime_type, 1153 | 'Offset': '0', 1154 | 'X-Entity-Name': upload_name, 1155 | 'X-Entity-Length': str(file_length), 1156 | 'Content-Type': 'application/octet-stream', 1157 | 'Content-Length': str(file_length), 1158 | } 1159 | 1160 | response = await self._private_post(url="https://www.instagram.com/rupload_igphoto/" + upload_name, headers=headers,data=file_data) 1161 | 1162 | if response['status'] == 'ok': 1163 | return response 1164 | else: 1165 | raise Exception("Failed to upload image") 1166 | 1167 | now = datetime.now() 1168 | 1169 | params = { 1170 | "text_post_app_info": {"reply_control": 0}, 1171 | "timezone_offset": str(self.settings.timezone_offset), 1172 | "source_type": "4", 1173 | "_uid": self.user_id, 1174 | "device_id": self.settings.device_id, 1175 | "caption": caption, 1176 | "upload_id": str(int(now.timestamp() * 1000)), 1177 | "device": self.settings.device_as_dict, 1178 | } 1179 | 1180 | post_url = POST_URL_TEXTONLY 1181 | if image_path is not None and url is None: 1182 | if isinstance(image_path, list): 1183 | if len(image_path) < 2: 1184 | raise Exception("Error: You must specify at least 2 image paths in `image_path` argument") 1185 | 1186 | if isinstance(image_path, str): 1187 | post_url = POST_URL_IMAGE 1188 | upload_id = await _upload_image(path=image_path) 1189 | if upload_id is None: 1190 | return False 1191 | params["upload_id"] = upload_id["upload_id"] 1192 | params["scene_capture_type"] = "" 1193 | elif isinstance(image_path, list): 1194 | post_url = POST_URL_SIDECAR 1195 | params['client_sidecar_id'] = generate_next_upload_id() 1196 | params["children_metadata"] = [] 1197 | for image in image_path: 1198 | upload_id = await _upload_image(path=image) 1199 | params["children_metadata"] += [{ 1200 | 'upload_id': upload_id["upload_id"], 1201 | 'source_type': '4', 1202 | 'timezone_offset': str(self.settings.timezone_offset), 1203 | 'scene_capture_type': "", 1204 | }] 1205 | else: 1206 | raise Exception(f"The image_path [{image_path}] is invalid.\n{OPEN_ISSUE_MESSAGE}") 1207 | elif url is not None: 1208 | params["text_post_app_info"]["link_attachment_url"] = url 1209 | 1210 | if image_path is None: 1211 | params["publish_mode"] = "text_post" 1212 | 1213 | if parent_post_id is not None: 1214 | params["text_post_app_info"]["reply_id"] = parent_post_id 1215 | 1216 | if quoted_post_id is not None: 1217 | params["text_post_app_info"]["quoted_post_id"] = quoted_post_id 1218 | 1219 | params = json.dumps(params) 1220 | payload = f"signed_body=SIGNATURE.{urllib.parse.quote(params)}" 1221 | headers = __get_app_headers().copy() 1222 | 1223 | try: 1224 | res = await self._private_post(url=post_url, headers=headers,data=payload) 1225 | 1226 | if 'status' in res and res['status'] == "ok": 1227 | return PostResponse(**res) 1228 | else: 1229 | raise Exception("Failed to post. Got response:\n" + str(res)) 1230 | except Exception as e: 1231 | print("[ERROR] ", e) 1232 | raise 1233 | -------------------------------------------------------------------------------- /threads_api/src/types.py: -------------------------------------------------------------------------------- 1 | from typing import List, Dict, Any, Optional 2 | from pydantic import BaseModel, Field 3 | 4 | class FriendshipStatus(BaseModel): 5 | following: Optional[bool] = None 6 | followed_by: Optional[bool] = None 7 | blocking: Optional[bool] = None 8 | muting: Optional[bool] = None 9 | is_private: Optional[bool] = None 10 | incoming_request: Optional[bool] = None 11 | outgoing_request: Optional[bool] = None 12 | is_bestie: Optional[bool] = None 13 | is_restricted: Optional[bool] = None 14 | is_feed_favorite: Optional[bool] = None 15 | subscribed: Optional[bool] = None 16 | is_eligible_to_subscribe: Optional[bool] = None 17 | text_post_app_pre_following: bool = None 18 | 19 | class FriendData(BaseModel): 20 | friendship_status: Optional[FriendshipStatus] = None 21 | previous_following: Optional[bool] = None 22 | status: Optional[str] = None 23 | 24 | class HdProfilePicInfo(BaseModel): 25 | url: str = None 26 | width: int = None 27 | height: int = None 28 | 29 | class User(BaseModel): 30 | has_anonymous_profile_picture: bool = None 31 | follower_count: int = None 32 | media_count: int = None 33 | following_count: int = None 34 | following_tag_count: int = None 35 | fbid_v2: str = None 36 | has_onboarded_to_text_post_app: bool = None 37 | show_text_post_app_badge: bool = None 38 | text_post_app_joiner_number: int = None 39 | show_ig_app_switcher_badge: bool = None 40 | pk: int = None 41 | pk_id: str = None 42 | username: str = None 43 | full_name: str = None 44 | is_private: bool = None 45 | is_verified: bool = None 46 | profile_pic_id: str = None 47 | profile_pic_url: str = None 48 | has_opt_eligible_shop: bool = None 49 | account_badges: List[str] = None 50 | third_party_downloads_enabled: int = None 51 | unseen_count: int = None 52 | friendship_status: FriendshipStatus = None 53 | latest_reel_media: int = None 54 | should_show_category: bool = None 55 | biography: str = None 56 | biography_with_entities: Dict[str, Any] = None 57 | can_link_entities_in_bio: bool = None 58 | external_url: str = None 59 | primary_profile_link_type: int = None 60 | show_fb_link_on_profile: bool = None 61 | show_fb_page_link_on_profile: bool = None 62 | can_hide_category: bool = None 63 | category: str = None 64 | is_category_tappable: bool = None 65 | is_business: bool = None 66 | professional_conversion_suggested_account_type: int = None 67 | account_type: int = None 68 | displayed_action_button_partner: str = None 69 | smb_delivery_partner: str = None 70 | smb_support_delivery_partner: str = None 71 | displayed_action_button_type: str = None 72 | smb_support_partner: str = None 73 | is_call_to_action_enabled: bool = None 74 | num_of_admined_pages: int = None 75 | page_id: str = None 76 | page_name: str = None 77 | ads_page_id: str = None 78 | ads_page_name: str = None 79 | bio_links: List[Dict[str, str]] = None 80 | can_add_fb_group_link_on_profile: bool = None 81 | eligible_shopping_signup_entrypoints: List[str] = None 82 | is_igd_product_picker_enabled: bool = None 83 | eligible_shopping_formats: List[str] = None 84 | needs_to_accept_shopping_seller_onboarding_terms: bool = None 85 | is_shopping_settings_enabled: bool = None 86 | is_shopping_community_content_enabled: bool = None 87 | is_shopping_auto_highlight_eligible: bool = None 88 | is_shopping_catalog_source_selection_enabled: bool = None 89 | current_catalog_id: str = None 90 | mini_shop_seller_onboarding_status: str = None 91 | shopping_post_onboard_nux_type: str = None 92 | ads_incentive_expiration_date: str = None 93 | can_be_tagged_as_sponsor: bool = None 94 | can_boost_post: bool = None 95 | can_convert_to_business: bool = None 96 | can_create_new_standalone_fundraiser: bool = None 97 | can_create_new_standalone_personal_fundraisers: bool = None 98 | can_create_sponsor_tags: bool = None 99 | can_see_organic_insights: bool = None 100 | has_chaining: bool = None 101 | has_guides: bool = None 102 | has_placed_orders: bool = None 103 | hd_profile_pic_url_info: HdProfilePicInfo = None 104 | hd_profile_pic_versions: List[HdProfilePicInfo] = None 105 | is_allowed_to_create_standalone_nonprofit_fundraisers: bool = None 106 | is_allowed_to_create_standalone_personal_fundraisers: bool = None 107 | pinned_channels_info: Dict[str, Any] = None 108 | show_conversion_edit_entry: bool = None 109 | show_insights_terms: bool = None 110 | show_text_post_app_switcher_badge: bool = None 111 | total_clips_count: int = None 112 | total_igtv_videos: int = None 113 | usertags_count: int = None 114 | usertag_review_enabled: bool = None 115 | fan_club_info: Optional[dict] = None 116 | transparency_product_enabled: Optional[bool] = None 117 | text_post_app_take_a_break_setting: Optional[int] = None 118 | interop_messaging_user_fbid: Optional[int] = None 119 | allowed_commenter_type: Optional[str] = None 120 | is_unpublished: Optional[bool] = None 121 | reel_auto_archive: Optional[str] = None 122 | feed_post_reshare_disabled: Optional[bool] = None 123 | show_account_transparency_details: Optional[bool] = None 124 | 125 | class UsersList(BaseModel): 126 | users: List[User] = None 127 | 128 | class ImageVersion(BaseModel): 129 | url: str = None 130 | 131 | class CarouselMedia(BaseModel): 132 | image_versions2: Dict[str, List[ImageVersion]] = None 133 | 134 | class VideoVersion(BaseModel): 135 | type: Optional[int] = None 136 | url: Optional[str] = None 137 | _typename: Optional[str] = Field(None, alias="__typename") 138 | 139 | class Caption(BaseModel): 140 | pk: Optional[str] = None 141 | user_id: Optional[int] = None 142 | text: Optional[str] = None 143 | type: Optional[int] = None 144 | created_at: Optional[int] = None 145 | created_at_utc: Optional[int] = None 146 | content_type: Optional[str] = None 147 | status: Optional[str] = None 148 | bit_flags: Optional[int] = None 149 | did_report_as_spam: Optional[bool] = None 150 | share_enabled: Optional[bool] = None 151 | user: Optional[User] = None 152 | is_covered: Optional[bool] = None 153 | is_ranked_comment: Optional[bool] = None 154 | media_id: Optional[int] = None 155 | private_reply_status: Optional[int] = None 156 | 157 | class ShareInfo(BaseModel): 158 | can_repost: Optional[bool] = None 159 | is_reposted_by_viewer: Optional[bool] = None 160 | repost_restricted_reason: Optional[str] = None 161 | can_quote_post: Optional[bool] = None 162 | quoted_post: Optional[dict] = None 163 | reposted_post: Optional[dict] = None 164 | 165 | class TextPostAppInfo(BaseModel): 166 | is_post_unavailable: Optional[bool] = None 167 | is_reply: Optional[bool] = None 168 | reply_to_author: Optional[User] = None 169 | direct_reply_count: Optional[int] = None 170 | self_thread_count: Optional[int] = None 171 | reply_facepile_users: List[dict] = None 172 | link_preview_attachment: Optional[dict] = None 173 | can_reply: Optional[bool] = None 174 | reply_control: Optional[str] = None 175 | hush_info: Optional[dict] = None 176 | share_info: Optional[ShareInfo] = None 177 | 178 | class VideoVersions(BaseModel): 179 | type: Optional[int] = None 180 | width: Optional[int] = None 181 | height: Optional[int] = None 182 | url: Optional[str] = None 183 | id: Optional[str] = None 184 | 185 | class ImageCandidate(BaseModel): 186 | width: Optional[int] = None 187 | height: Optional[int] = None 188 | url: Optional[str] = None 189 | scans_profile: Optional[str] = None 190 | 191 | class ImageVersions2(BaseModel): 192 | candidates: Optional[List[ImageCandidate]] = None 193 | 194 | class Post(BaseModel): 195 | pk: Optional[int] = None 196 | id: Optional[str] = None 197 | taken_at: Optional[int] = None 198 | device_timestamp: Optional[int] = None 199 | client_cache_key: Optional[str] = None 200 | filter_type: Optional[int] = None 201 | like_and_view_counts_disabled: Optional[bool] = None 202 | integrity_review_decision: Optional[str] = None 203 | text_post_app_info: Optional[TextPostAppInfo] = None 204 | caption: Optional[Caption] = None 205 | media_type: Optional[int] = None 206 | code: Optional[str] = None 207 | carousel_media: List[CarouselMedia] = None 208 | carousel_media_count: int = None 209 | product_type: Optional[str] = None 210 | organic_tracking_token: Optional[str] = None 211 | image_versions2: Optional[ImageVersions2] = None 212 | original_width: Optional[int] = None 213 | original_height: Optional[int] = None 214 | is_dash_eligible: Optional[int] = None 215 | is_dash: Optional[bool] = None 216 | video_dash_manifest: Optional[str] = None 217 | video_codec: Optional[str] = None 218 | has_audio: Optional[bool] = None 219 | video_duration: Optional[float] = None 220 | video_versions: List[VideoVersions] = None 221 | like_count: Optional[int] = None 222 | has_liked: Optional[bool] = None 223 | can_viewer_reshare: Optional[bool] = None 224 | top_likers: List[dict] = [] 225 | user: Optional[User] = None 226 | media_overlay_info: Dict[str, Any] = None 227 | logging_info_token: Optional[str] = None 228 | 229 | class ThreadItem(BaseModel): 230 | post: Post = None 231 | line_type: str = None 232 | view_replies_cta_string: str = None 233 | reply_facepile_users: List[Dict[str, Any]] = None 234 | should_show_replies_cta: bool = None 235 | reply_to_author: Optional[User] = None 236 | can_inline_expand_below: bool = None 237 | _typename: Optional[str] = Field(None, alias="__typename") 238 | 239 | class Thread(BaseModel): 240 | thread_items: List[ThreadItem] = None 241 | id: str = None 242 | thread_type: str = None 243 | header: Optional[dict] = None 244 | 245 | class Threads(BaseModel): 246 | threads: List[Thread] = None 247 | 248 | class Replies(BaseModel): 249 | containing_thread: Optional[Thread] = None 250 | reply_threads: Optional[List[Thread]] = None 251 | 252 | class FundraiserTag(BaseModel): 253 | has_standalone_fundraiser: Optional[bool] = None 254 | 255 | 256 | class MusicMetadata(BaseModel): 257 | music_canonical_id: Optional[str] = None 258 | audio_type: Optional[str] = None 259 | music_info: Optional[dict] = None 260 | original_sound_info: Optional[dict] = None 261 | pinned_media_ids: Optional[dict] = None 262 | 263 | class SharingFrictionInfo(BaseModel): 264 | should_have_sharing_friction: Optional[bool] = None 265 | bloks_app_url: Optional[str] = None 266 | sharing_friction_payload: Optional[str] = None 267 | 268 | class MashupInfo(BaseModel): 269 | mashups_allowed: Optional[bool] = None 270 | can_toggle_mashups_allowed: Optional[bool] = None 271 | has_been_mashed_up: Optional[bool] = None 272 | is_light_weight_check: Optional[bool] = None 273 | formatted_mashups_count: Optional[int] = None 274 | original_media: Optional[dict] = None 275 | privacy_filtered_mashups_media_count: Optional[int] = None 276 | non_privacy_filtered_mashups_media_count: Optional[int] = None 277 | mashup_type: Optional[str] = None 278 | is_creator_requesting_mashup: Optional[bool] = None 279 | has_nonmimicable_additional_audio: Optional[bool] = None 280 | is_pivot_page_available: Optional[bool] = None 281 | 282 | class MediaData(BaseModel): 283 | taken_at: Optional[int] = None 284 | pk: Optional[int] = None 285 | id: Optional[str] = None 286 | device_timestamp: Optional[int] = None 287 | client_cache_key: Optional[str] = None 288 | filter_type: Optional[int] = None 289 | fundraiser_tag: Optional[FundraiserTag] = None 290 | caption_is_edited: Optional[bool] = None 291 | like_and_view_counts_disabled: Optional[bool] = None 292 | is_in_profile_grid: Optional[bool] = None 293 | is_reshare_of_text_post_app_media_in_ig: Optional[bool] = None 294 | media_type: Optional[int] = None 295 | code: Optional[str] = None 296 | can_viewer_reshare: Optional[bool] = None 297 | caption: Optional[Caption] = None 298 | clips_tab_pinned_user_ids: Optional[List[dict]] = None 299 | comment_inform_treatment: Optional[dict] = None 300 | sharing_friction_info: Optional[SharingFrictionInfo] = None 301 | xpost_deny_reason: Optional[str] = None 302 | original_media_has_visual_reply_media: Optional[bool] = None 303 | fb_user_tags: Optional[dict] = None 304 | mashup_info: Optional[MashupInfo] = None 305 | can_viewer_save: Optional[bool] = None 306 | profile_grid_control_enabled: Optional[bool] = None 307 | featured_products: Optional[List[dict]] = None 308 | is_comments_gif_composer_enabled: Optional[bool] = None 309 | product_suggestions: Optional[List[dict]] = None 310 | user: Optional[User] = None 311 | image_versions2: Optional[dict] = None 312 | original_width: Optional[int] = None 313 | original_height: Optional[int] = None 314 | max_num_visible_preview_comments: Optional[int] = None 315 | has_more_comments: Optional[bool] = None 316 | comment_threading_enabled: Optional[bool] = None 317 | preview_comments: Optional[List[dict]] = None 318 | comment_count: Optional[int] = None 319 | can_view_more_preview_comments: Optional[bool] = None 320 | hide_view_all_comment_entrypoint: Optional[bool] = None 321 | likers: Optional[List[dict]] = None 322 | shop_routing_user_id: Optional[str] = None 323 | can_see_insights_as_brand: Optional[bool] = None 324 | is_organic_product_tagging_eligible: Optional[bool] = None 325 | music_metadata: Optional[MusicMetadata] = None 326 | deleted_reason: Optional[int] = None 327 | integrity_review_decision: Optional[str] = None 328 | has_shared_to_fb: Optional[int] = None 329 | is_unified_video: Optional[bool] = None 330 | should_request_ads: Optional[bool] = None 331 | is_visual_reply_commenter_notice_enabled: Optional[bool] = None 332 | commerciality_status: Optional[str] = None 333 | explore_hide_comments: Optional[bool] = None 334 | product_type: Optional[str] = None 335 | is_paid_partnership: Optional[bool] = None 336 | organic_tracking_token: Optional[str] = None 337 | text_post_app_info: Optional[dict] = None 338 | ig_media_sharing_disabled: Optional[bool] = None 339 | has_delayed_metadata: Optional[bool] = None 340 | 341 | class PostResponse(BaseModel): 342 | media: Optional[MediaData] = None 343 | upload_id: Optional[str] = None 344 | status: Optional[str] = None 345 | 346 | class CarouselMedia(BaseModel): 347 | media_type: Optional[int] = None 348 | image_versions2: Optional[ImageVersions2] = None 349 | original_width: Optional[int] = None 350 | original_height: Optional[int] = None 351 | pk: Optional[int] = None 352 | 353 | class Comment(BaseModel): 354 | pk: Optional[int] = None 355 | user_id: Optional[int] = None 356 | text: Optional[str] = None 357 | created_at: Optional[int] = None 358 | content_type: Optional[str] = None 359 | status: Optional[str] = None 360 | share_enabled: Optional[bool] = None 361 | has_translation: Optional[bool] = None 362 | parent_comment_id: Optional[int] = None 363 | media_info: Optional[Post] = None 364 | user: Optional[User] = None 365 | 366 | class TimelineItem(BaseModel): 367 | thread_items: Optional[List[ThreadItem]] = None 368 | header: Optional[dict] = None 369 | thread_type: Optional[str] = None 370 | show_create_reply_cta: Optional[bool] = None 371 | id: Optional[int] = None 372 | view_state_item_type: Optional[int] = None 373 | posts: List[Post] = [] 374 | 375 | class TimelineData(BaseModel): 376 | num_results: Optional[int] = None 377 | more_available: Optional[bool] = None 378 | auto_load_more_enabled: Optional[bool] = None 379 | is_direct_v2_enabled: Optional[bool] = None 380 | next_max_id: Optional[str] = None 381 | view_state_version: Optional[str] = None 382 | client_feed_changelist_applied: Optional[bool] = None 383 | request_id: Optional[str] = None 384 | pull_to_refresh_window_ms: Optional[int] = None 385 | preload_distance: Optional[int] = None 386 | status: Optional[str] = None 387 | pagination_source: Optional[str] = None 388 | hide_like_and_view_counts: Optional[int] = None 389 | last_head_load_ms: Optional[int] = None 390 | is_shell_response: Optional[bool] = None 391 | feed_items_media_info : Optional[List[dict]] = None 392 | items: List[TimelineItem] = [] 393 | -------------------------------------------------------------------------------- /threads_api/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Danie1/threads-api/00ce459eff46c6109928b9ef9734eba91f499fd0/threads_api/tests/__init__.py -------------------------------------------------------------------------------- /threads_api/tests/ut/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Danie1/threads-api/00ce459eff46c6109928b9ef9734eba91f499fd0/threads_api/tests/ut/__init__.py -------------------------------------------------------------------------------- /threads_api/tests/ut/get_post_test.py: -------------------------------------------------------------------------------- 1 | from threads_api.src.threads_api import ThreadsAPI 2 | from threads_api.src.http_sessions.abstract_session import HTTPSession 3 | 4 | import pytest 5 | from unittest.mock import AsyncMock, patch 6 | import json 7 | 8 | class HTTPSessionMock: 9 | def __init__(self): 10 | # Create MagicMock objects for each method 11 | self.start = AsyncMock() 12 | self.auth = AsyncMock() 13 | self.close = AsyncMock() 14 | self.post = AsyncMock() 15 | self.get = AsyncMock() 16 | self.download = AsyncMock() 17 | 18 | async def refresh_public_token(): 19 | return "asfdasdfa" 20 | 21 | async def get_user_id_from_username(username): 22 | return "12345678" 23 | 24 | @pytest.mark.asyncio 25 | async def test_get_post(mocker): 26 | api = ThreadsAPI(http_session_class=HTTPSessionMock) 27 | 28 | mocker.patch('threads_api.src.threads_api.ThreadsAPI._refresh_public_token', side_effect=refresh_public_token) 29 | api._public_session.post.return_value = json.dumps({'status':'ok', 'data': {'data': {}}}) 30 | 31 | await api.get_post(1234567890) 32 | return 33 | 34 | @pytest.mark.asyncio 35 | async def test_get_post_logged_in(mocker): 36 | api = ThreadsAPI(http_session_class=HTTPSessionMock) 37 | 38 | #mocker.patch('threads_api.src.threads_api.ThreadsAPI.get_user_id_from_username', side_effect=get_user_id_from_username) 39 | with patch.object(api, 'get_user_id_from_username', return_value="asadfasdf") as p: 40 | await api.login(username="", password="") 41 | 42 | mocker.patch('threads_api.src.threads_api.ThreadsAPI._refresh_public_token', side_effect=refresh_public_token) 43 | api._auth_session.get.return_value = json.dumps({'status':'ok'}) 44 | 45 | await api.get_post(1234567890) 46 | return --------------------------------------------------------------------------------