├── .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 | [](https://pypi.org/project/threads-api/)
9 | [](https://pypi.org/project/threads-api/)
10 | [](https://github.com/danie1/threads-api/releases)
11 | [](https://pypi.org/project/threads-api/) [](https://github.com/Danie1/threads-api/blob/main/LICENSE)
12 |
13 | [](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 |
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
--------------------------------------------------------------------------------