You can scrape public Threads profiles, a profile's recent posts, a post's replies and keyword search with one Python file and no login: toolzerhub/threads-scraper on GitHub. It is 333 lines and depends on one package, curl_cffi. On 2026-10-17, through a residential proxy, it returned 100 of NASA's posts in 13.7 seconds; from our datacenter server with no proxy, paging past a profile's first 5 posts answered HTTP 401.
How do you set up the Threads scraper?
Clone the repo, install curl_cffi and run profile on any public username. We ran everything on 2026-10-17 with Python 3.12.13 and curl_cffi 0.16.3, at commit 6a20b57. Times are through a residential proxy unless a line says datacenter, our server with no proxy.
git clone https://github.com/toolzerhub/threads-scraper.git
cd threads-scraper
pip install -r requirements.txt
python threads_scraper.py profile nasa
The script opens one curl_cffi session that sends requests as Chrome. Threads pages carry their data as JSON in <script type="application/json"> tags: the nasa profile page held 45 of them, 589,495 bytes of JSON in a 955,254-byte page. The script pulls them out with a regular expression (JSON_SCRIPT), so it needs no browser.
def page_blobs(html):
"""The page carries its data as JSON in <script type="application/json"> tags."""
blobs = []
for raw in JSON_SCRIPT.findall(html):
try:
blobs.append(json.loads(raw))
except ValueError:
continue
return blobs
class Threads:
def __init__(self, proxy=None):
# One Chrome-like session: the first page sets the cookies the later queries need.
self.http = requests.Session(impersonate="chrome", proxies={"http": proxy, "https": proxy} if proxy else None)
# ...
def page(self, path):
"""Fetch a page and return its embedded JSON blobs."""
headers = {"accept": "text/html,application/xhtml+xml,*/*;q=0.8", "accept-language": "en-US,en;q=0.9"}
html = self._send("GET", BASE + path, headers=headers).text
if "Barcelona404ErrorRoot" in html:
raise ScrapeError(f"Not found: {BASE + path}")
blobs = page_blobs(html)
if not blobs:
raise ScrapeError("The page had no data. Threads may have changed its pages, or this IP is blocked.")
return blobs
# --- profile and posts -------------------------------------------------
Every command takes -o file.json to save the result and --proxy URL to route through a proxy. The script also reads HTTPS_PROXY.
How do you scrape a Threads profile?
Run profile with one or more usernames; it returns the bio, bio links, follower count and the verified and private flags. _user_page loads threads.com/@username, then walks every JSON blob until it finds a record with that username and a follower_count. It finds the record by its shape, so it does not depend on a fixed path.
def _user_page(self, username):
blobs = self.page(f"/@{quote(username)}")
for node in walk(blobs):
if str(node.get("username", "")).lower() == username.lower() and "follower_count" in node:
return node, blobs
raise ScrapeError(f"No profile data on the page for {username}. The user may not exist.")
def profile(self, username):
user, _ = self._user_page(username)
pics = user.get("hd_profile_pic_versions") or []
return {
"id": str(user.get("pk") or user.get("id")),
"username": user.get("username"),
"url": f"{BASE}/@{user.get('username')}",
"full_name": user.get("full_name"),
"biography": user.get("biography"),
"bio_links": [link.get("url") for link in user.get("bio_links") or [] if link.get("url")],
"followers_count": user.get("follower_count"),
"is_verified": user.get("is_verified"),
"is_private": user.get("text_post_app_is_private"),
"profile_pic_url": pics[-1].get("url") if pics else user.get("profile_pic_url"),
}
profile nasa took 3.1 seconds through the proxy, and 1.9 and 2.0 seconds from the datacenter server. Trimmed output (the follower count moved between our runs, 12,781,471 to 12,781,503, so it is live):
{
"id": "28829152388",
"username": "nasa",
"url": "https://www.threads.com/@nasa",
"full_name": "NASA",
"biography": "Making the seemingly impossible, possible. ✨",
"bio_links": [],
"followers_count": 12781503,
"is_verified": true,
"is_private": false,
"profile_pic_url": "https://scontent-ord5-1.cdninstagram.com/v/t51.82787-19/..."
}
Ten accounts in one command (nasa, natgeo, zuck and seven more) returned 10 of 10 in 16.4 seconds through the proxy and 16.2 from the datacenter server. The signed profile_pic_url expires, so download images soon.
Threads is a class you can import, so a list of usernames becomes a CSV in a few lines. This script reuses one session, waits 2 seconds between names and reports the ones that fail. It is the version we ran.
import csv
import sys
import time
from threads_scraper import ScrapeError, Threads
FIELDS = ["username", "full_name", "followers_count", "is_verified", "is_private", "bio_links"]
def main(path, usernames):
threads = Threads() # one session for the whole list; pass a proxy URL here if you need one
done = 0
with open(path, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=FIELDS, extrasaction="ignore")
writer.writeheader()
for name in usernames:
try:
row = threads.profile(name)
row["bio_links"] = " ".join(row["bio_links"])
writer.writerow(row)
done += 1
except ScrapeError as exc:
print(f"{name}: {exc}", file=sys.stderr)
time.sleep(2) # pause between profiles
print(f"wrote {done} of {len(usernames)} profiles to {path}")
if done == 0:
sys.exit(1)
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2:])
python profiles_to_csv.py profiles.csv nasa natgeo spotify netflix nba nytimes mosseri zz_no_such_user_88123
Output on 2026-10-17 from the datacenter server, where profiles work, after 30 seconds (about 16 of them are the pauses):
zz_no_such_user_88123: Not found: https://www.threads.com/@zz_no_such_user_88123. If it does exist, Threads may be rate-limiting this IP; try again, use a home connection or --proxy.
wrote 7 of 8 profiles to profiles.csv
username,full_name,followers_count,is_verified,is_private,bio_links
nasa,NASA,12781489,True,False,
natgeo,National Geographic,18380926,True,False,https://on.natgeo.com/instagram
netflix,Netflix US,8603346,True,False,
mosseri,Adam Mosseri,1049999,True,False,http://mosseri.net/
The file has all 7 rows; we show 4.
How do you scrape a profile's posts and page through them?
Run posts <username> --limit N. The profile page already embeds the first posts, so the script reads those, and for more it sends the same GraphQL query the Threads website sends for the next page, using the cursor from the page. The loop below is the whole paging logic.
def posts(self, username, limit=20):
user, blobs = self._user_page(username)
feed = next((n for n in walk(blobs) if isinstance(n.get("edges"), list) and isinstance(n.get("page_info"), dict)
and any("thread_items" in (e.get("node") or {}) for e in n["edges"])), None)
if feed is None:
raise ScrapeError(f"No posts on the page for {username}. The account may be private or have no posts.")
out = []
while True:
for edge in feed["edges"]:
for item in edge["node"].get("thread_items") or []:
if item.get("post"):
out.append(clean_post(item["post"]))
info = feed.get("page_info") or {}
if len(out) >= limit or not info.get("has_next_page") or not info.get("end_cursor"):
break
feed = self._more_posts(str(user.get("pk") or user.get("id")), info["end_cursor"])
return out[:limit]
posts nasa --limit 5 took 3.6 seconds through the proxy and returned 5 posts. The profile page holds 5 posts for nasa, natgeo and nba, and 10 for zuck, so any --limit above that needs the second query. The five posts, with the counts as they stood:
code created_at type likes replies reposts quotes is_reply
DekCdIIEZCG 2026-10-16 text 280 16 14 2 false
Decc755lYhC 2026-10-13 carousel 41036 586 4367 281 false
Decc8jiFYaY 2026-10-13 text 4182 17 89 11 true
DeaX_rOGaz- 2026-10-12 image 2290 58 105 13 false
DeUWxGgjHSK 2026-10-10 image 1969 55 103 5 false
The third row is NASA replying in its own thread: posts returns an account's replies too, so filter on is_reply. Paging worked through the proxy. posts nasa --limit 40 returned 40 distinct posts on 3 of 3 runs, in 6.4, 6.7 and 8.0 seconds. posts nasa --limit 100 returned 100 distinct posts in 13.7 seconds, back to 2026-06-01, 27 of them replies; posts natgeo --limit 100 took 16.2 seconds and reached 2026-08-03. We stopped at 100, so we don't know how deep paging goes.
How do you scrape a post's replies?
Run post <url>; it returns the post and a replies list. A Threads post page holds the post and its first replies as JSON, in a second record that shares the post's id. The script looks that record up by id and keeps only the entries under direct_replies, because the other posts on the page are recommendations.
def post(self, url):
path = post_path_of(url)
code = path.rsplit("/", 1)[1]
blobs = self.page(path)
post = next((n for n in walk(blobs) if n.get("code") == code and n.get("user") and n.get("taken_at")), None)
if post is None:
raise ScrapeError(f"Post not found or not public: {url}")
result = clean_post(post)
# Replies sit in a second record with the same id as the post. The "related
# posts" elsewhere on the page are recommendations, not replies.
host = next((n for n in walk(blobs) if n.get("id") == post.get("id") and (n.get("text_post_app_info") or {}).get("direct_replies")), None)
replies = (((host or {}).get("text_post_app_info") or {}).get("direct_replies") or {}).get("edges") or []
result["replies"] = [
clean_post(r["node"])
for edge in replies
for r in ((edge.get("node") or {}).get("posts") or {}).get("edges") or []
if r.get("node")
]
return result
# --- search ------------------------------------------------------------
On two NASA posts, the one with 586 replies returned 11 in 3.0 seconds through the proxy, and the one with 58 returned 9 in 6.6. Both also worked from the datacenter server (1.7 and 2.2 seconds). Each reply has a post's fields and is_reply true. The README says a post page shows around 10 threads, so that is the page's limit, and the script cannot ask for more.
How do you search Threads by keyword?
Run search <keyword>; it returns the public posts Threads shows a logged-out visitor, about 20 to 29, with no second page. The script loads threads.com/search, finds the result list by its shape (edges that hold thread records) and cleans each post.
def search(self, keyword, limit=20):
"""Threads shows about 20 results to logged-out visitors and no second page."""
blobs = self.page(f"/search?{urlencode({'q': keyword, 'serp_type': 'default'})}")
results = next((n for n in walk(blobs) if isinstance(n.get("edges"), list)
and any(isinstance((e.get("node") or {}).get("thread"), dict) for e in n["edges"])), None)
if results is None:
raise ScrapeError(f"No search results for {keyword!r}.")
out = []
for edge in results["edges"]:
for item in edge["node"]["thread"].get("thread_items") or []:
if item.get("post"):
out.append(clean_post(item["post"]))
return out[:limit]
search astronomy --limit 20 took 5.8 seconds through the proxy and returned 20 posts from 17 accounts, 3 of them verified. Posts ran from 2025-07-16 to 2026-10-17, the median had 810 likes and the top post 11,290; 14 were text, 3 images, 2 carousels and 1 video. The datacenter run took 4.5 seconds. The cap is --limit, not 20: --limit 40 returned 29 posts for "astronomy" on 3 of 3 runs and 22 for "bitcoin". Results are not sorted by date. Meta's own keyword search needs app approval; see Threads API keyword search for what it allows.
Why does Threads block some requests?
Threads answers some requests with a redirect, 401, 403 or 429, and the script turns each into an error. The README says cloud and datacenter IPs are blocked most and suggests a home connection or --proxy. This is the function that decides:
def _send(self, method, url, **kwargs):
try:
resp = self.http.request(method, url, timeout=30, allow_redirects=False, **kwargs)
except requests.RequestsError as exc:
raise ScrapeError(f"Network error: {exc}") from exc
where = resp.headers.get("location") or ""
# Threads sends missing users and posts to its login page or home page with an error.
# The same redirect shows up when it rate-limits an IP, so the message names both.
if resp.status_code in (301, 302) and ("/login" in where or "error=" in where):
raise ScrapeError(
f"Not found: {url}. If it does exist, Threads may be rate-limiting this IP; "
"try again, use a home connection or --proxy."
)
if resp.status_code in (301, 302, 401, 403, 429):
raise ScrapeError(
f"Threads answered HTTP {resp.status_code}: rate-limited or blocked. "
"Cloud and datacenter IPs are blocked most; try a home connection or --proxy."
)
if resp.status_code == 404:
raise ScrapeError("Not found.")
if resp.status_code != 200:
raise ScrapeError(f"Unexpected HTTP {resp.status_code} from Threads.")
We ran every command twice on 2026-10-17: from our datacenter server with no proxy, and through a residential proxy. Our test was one server and one proxy provider.
| Command | Datacenter, no proxy | Residential proxy |
|---|---|---|
profile nasa |
1.9 s, data | 3.1 s, data |
posts nasa --limit 5 |
1.7 s, 5 posts | 3.6 s, 5 posts |
posts nasa --limit 40 |
HTTP 401 on 3 of 3 | 40 posts in 6.4 to 8.0 s on 3 of 3 |
posts nasa --limit 100 |
HTTP 401 | 100 posts in 13.7 s |
post with 586 replies |
1.7 s, 11 replies | 3.0 s, 11 replies |
search astronomy |
4.5 s, 20 posts | 5.8 s, 20 posts |
The README's warning held for one query only. Profile pages, a profile's first posts, a post with replies and search all worked from the datacenter server. The query for the next page of posts answered 401 on 5 of 5 tries there and worked on 6 of 6 through the proxy (3 runs of nasa at 40 posts, one of natgeo at 40, one of each at 100). On the commands both connections could run, the proxy was slower by 0.2 to 4.4 seconds, and its cost is yours.
A handle can come back as "Not found" on both connections: cristiano and the made-up zz_no_such_user_88123 did. The message names two causes because Threads sends the same redirect for a missing user and for a rate-limited IP. A logged-out Chromium window showed only a login prompt for both, so we can't tell a missing account from one that Threads hides from logged-out visitors; cristiano is a real account.
Two limits to plan for. The script's own comment says the DOC_PROFILE_POSTS paging id changes now and then, and it has no retries, so a timeout surfaces as an error and you rerun that name.
What the script can't do, and the API calls that do
The free script cannot page a profile's posts from a datacenter IP, returns only the first replies of a post, and has no second page of search. We make ToolzerHub, and its Threads API pages a profile's posts with one API key and no proxy of yours. On 2026-10-17 we sent a profile's id as user_id, from the same server where the script's paging failed:
curl -H "x-api-key: $TOOLZERHUB_API_KEY" \
"https://api.toolzerhub.com/v1/threads/profile/posts?user_id=28829152388"
The first call took 2.1 seconds and returned 21 posts, with has_next_page true. Trimmed to the fields the script also returns:
{
"data": {
"edges": [
{
"node": {
"thread_items": [
{
"post": {
"code": "DekCdIIEZCG",
"taken_at": 1792170638,
"like_count": 280,
"text_post_app_info": { "direct_reply_count": 16, "repost_count": 14, "quote_count": 2 }
}
}
]
}
}
],
"page_info": { "has_next_page": true, "end_cursor": "QVFDbmR0..." }
}
}
We sent each end_cursor back as end_cursor until has_next_page was false. For NASA that took 10 calls and 38.6 seconds and returned 193 posts with no duplicates, from 2026-04-11 to 2026-10-16: the script's 100 posts through the proxy reached back to 2026-06-01. The response is Threads' own record, with many more fields than the script's cleaned ones; user_id is the id that profile returns.
The API's post endpoint returned the same 11 replies on the 586-reply post, so it does not fill that gap either. One API search page held 26 posts for "astronomy" with no cursor for more; the script returned 29 with --limit 40. Neither has a second page.
What the API costs
A free key gives you 100 credits a month with no card: get one here. Every Threads call is 1 credit. Paid plans are $29 for 20,000 credits ($1.45 per 1,000), $79 for 60,000 ($1.32), $199 for 165,000 ($1.21) and $495 for 500,000 ($0.99), so 1,000 calls cost $0.99 to $1.45. At 193 posts per 10 calls, about 19 a call, 1,000 posts is about 52 calls: 52 credits, or 5 to 8 cents.
For profile posts, the 7 days to 2026-10-17 had 625 successful calls and 2 server errors, and none of the 625 was a 200 with an empty body (under 40 bytes). We also ran 12 live calls on 12 known accounts, and all 12 returned posts.
Is it legal to scrape Threads?
Threads' Terms of Use forbid it. Under "How You Can't Use the Threads Service", they say you may not "use any robot, spider, crawlers, scraper or other automatic device, process, software or queries that intercepts, “mines”, scrapes, extracts or otherwise accesses the Threads Service to monitor, extract, copy or collect information or data from the Threads Service". The same clause bars you from exploiting the Threads Service for any commercial purpose. The terms were last updated 4 December 2023. The script reads only what a visitor without an account sees, and the clause makes no exception for logged-out access. This is not legal advice.
Profiles and replies are personal data, and laws such as the GDPR (EU and UK) and the CCPA (California) may apply: collect only what you need and delete it when you are done. If the data is yours, Meta's own Threads API is the supported route; what Meta's Threads API returns covers what it allows for other accounts.
We are not affiliated with Meta Platforms, Inc. or Threads.
Sources
- toolzerhub/threads-scraper, source and README (commit 6a20b57), https://github.com/toolzerhub/threads-scraper, run 2026-10-17
- Threads Terms of Use (last updated 4 December 2023), https://help.instagram.com/769983657850450, read 2026-10-17
- ToolzerHub pricing, https://toolzerhub.com/pricing, checked 2026-10-17
- ToolzerHub API OpenAPI spec (credits per endpoint), https://api.toolzerhub.com/openapi.json, read 2026-10-17