You can scrape public TikTok profiles, videos, comments and hashtags with one Python file and no login: our free script toolzerhub/tiktok-scraper on GitHub. It is 297 lines and depends on one package, curl_cffi. On 2026-10-17 it read 30 profiles in 14.5 seconds from our datacenter server with no proxy. It stops at 11 videos per creator and 10 top videos per hashtag.
How do you set up the TikTok 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 60f6baa; the comment runs are from b339fa7, which changed only how comments pages. Times are from our datacenter server with no proxy, a Hostinger IP in Germany, unless a line says residential.
git clone https://github.com/toolzerhub/tiktok-scraper.git
cd tiktok-scraper
pip install -r requirements.txt
python tiktok_scraper.py profile nasa
The script opens one curl_cffi session that sends requests as Safari; its comment says a newer Chrome got an empty refusal on video pages. A profile page carries its data as JSON in one <script> tag (263,029 of the nasa page's 387,110 bytes), and a regular expression pulls it out, so no browser runs.
BROWSER = "safari184"
HEADERS = {"Accept-Language": "en-US,en;q=0.9"}
PAGE_DATA = re.compile(r'<script id="__UNIVERSAL_DATA_FOR_REHYDRATION__" type="application/json">(.*?)</script>', re.S)
_page_scope returns one named scope of that JSON; profile and video share it.
def _page_scope(self, url, scope, what):
"""Load a normal TikTok page and return one scope of its embedded JSON data."""
html = self._get(url, what).text
match = PAGE_DATA.search(html)
if not match:
raise ScrapeError(f"No data on the page for {what}. TikTok may have changed its pages, or blocked this IP.")
return json.loads(match.group(1)).get("__DEFAULT_SCOPE__", {}).get(scope)
Every command takes -o file.json to save the result and --proxy URL to route through a proxy.
How do you scrape a TikTok profile?
Run profile with one or more usernames; it returns the bio, link in bio, verified and private flags, join date and exact follower, following, like and video counts. profile loads tiktok.com/@username and reads the webapp.user-detail scope. It prefers statsV2, which holds exact counts, over the older stats block that rounds big numbers.
def profile(self, username):
detail = self._page_scope(f"{BASE}/@{quote(username)}", "webapp.user-detail", f"profile {username}")
info = (detail or {}).get("userInfo")
if not info:
raise ScrapeError(f"Profile not found: {username}")
user = info["user"]
# statsV2 has exact counts; the older "stats" block rounds big numbers.
stats = info.get("statsV2") or info.get("stats") or {}
profile nasa took 0.8 seconds from the datacenter server and 1.9 through the residential proxy. Trimmed output:
{
"id": "7664638705177150477",
"username": "nasa",
"url": "https://www.tiktok.com/@nasa",
"nickname": "NASA",
"bio": "Making the seemingly impossible, possible.✨",
"bio_link": "science.nasa.gov/feature/artemis-ii-preliminary-lunar-science-report",
"is_verified": true,
"is_private": false,
"created_at": "2026-07-20T15:55:49+00:00",
"followers_count": 2097088,
"following_count": 23,
"likes_count": 11135867,
"videos_count": 57,
"avatar_url": "https://p16-common-sign.tiktokcdn-us.com/tos-useast5-avt-0068-tx/..."
}
Thirty accounts in one command, from nasa to tomholland, returned 30 of 30 in 14.5 and 34.0 seconds. The avatar link is signed and expires.
How do you get one video's stats?
Run video with a video URL or just its numeric id; it returns the caption, hashtags, play, like, comment, share and save counts, the author and the sound. The id alone works because TikTok's page redirects any name to the real author, and the script sends _ as the name.
video 7665075736742530317 took 1.1 seconds from the datacenter server and 3.4 through the proxy; the full URL took 1.0 and 2.6. Trimmed output, with counts that were up 1,500 likes from the README's example:
{
"id": "7665075736742530317",
"description": "Something big just landed on TikTok.",
"created_at": "2026-07-21T20:09:02+00:00",
"duration_seconds": 25,
"hashtags": [],
"play_count": 1600000,
"like_count": 105100,
"comment_count": 3068,
"share_count": 3275,
"save_count": 7630,
"username": "nasa",
"music_title": "original sound"
}
How do you list a creator's videos?
Run videos <username>; it returns about 11 of the creator's latest public videos, with no next page. The script reads TikTok's embed page for the creator, tiktok.com/embed/@nasa, the page other sites use for widgets. It holds a short video list as JSON, 25,673 of the page's 294,987 bytes.
def _embed(self, path, what, missing="Not found or not public"):
"""Load a TikTok embed page and return its data block. HTTP 400 means no such item."""
resp = self._get(f"{BASE}/embed/{path}", what, ok_statuses=(200, 400, 404))
match = EMBED_DATA.search(resp.text)
# ...
# ...
videos nasa returned 11 videos in 1.0 second from the datacenter server and 1.7 through the proxy, and --limit 100 returned the same 11, while NASA's profile says 57. The first video is from 2026-07-21 and the other ten run from 2026-10-16 back to 2026-09-25. Each item has the caption, play count and links; its time is read from the id, and one was 25 seconds off the time on its own page.
--details opens each video page too, for exact likes, comments, shares and saves: 11 videos took 6.6 seconds from the datacenter server and 16.0 through the proxy. TikTok is a class you can import, so several creators become one CSV. This script reuses one session and waits 2 seconds between creators. Save it next to the script.
import argparse
import csv
import os
import sys
import time
from tiktok_scraper import ScrapeError, TikTok
FIELDS = ["username", "id", "created_at", "play_count", "like_count", "comment_count",
"share_count", "save_count", "duration_seconds", "hashtags", "description", "url"]
def main():
parser = argparse.ArgumentParser(description="Latest videos of several creators, with exact counts, to one CSV.")
parser.add_argument("output")
parser.add_argument("usernames", nargs="+")
parser.add_argument("--proxy", default=os.environ.get("HTTPS_PROXY"))
args = parser.parse_args()
tiktok = TikTok(args.proxy) # one session for the whole list
rows = 0
with open(args.output, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=FIELDS, extrasaction="ignore")
writer.writeheader()
for name in args.usernames:
try:
for video in tiktok.videos(name, details=True):
video["hashtags"] = " ".join(video["hashtags"])
writer.writerow(video)
rows += 1
except ScrapeError as exc:
print(f"{name}: {exc}", file=sys.stderr)
time.sleep(2) # pause between creators
print(f"wrote {rows} videos to {args.output}")
if rows == 0:
sys.exit(1)
if __name__ == "__main__":
main()
python videos_to_csv.py videos.csv nasa natgeo duolingo zz_no_such_user_88123
Output on 2026-10-17 from the datacenter server, after 28.4 seconds (the proxy run took 48.4):
zz_no_such_user_88123: Not found or not public: videos of zz_no_such_user_88123
wrote 33 videos to videos.csv
username,id,created_at,play_count,like_count,comment_count,share_count,save_count,duration_seconds,hashtags,description,url
nasa,7665075736742530317,2026-07-21T20:09:02+00:00,1600000,105100,3068,3275,7630,25,,Something big just landed on TikTok.,https://www.tiktok.com/@nasa/video/7665075736742530317
nasa,7697300954667994381,2026-10-16T16:19:26+00:00,101600,9862,253,293,777,121,,There was plenty to see across NASA this week 🚀 ...
The file has 33 rows, 11 per creator; we show 2, with the second caption cut short.
How do you scrape a TikTok video's comments?
Run comments <url> --limit N; it returns top-level comments with text, time, likes, reply count and author, from the JSON endpoint the TikTok web page uses, 50 a page. On long threads TikTok sets has_more to false early: at 391 on the NASA video, where the script's first version stopped. Later pages still hold comments, so it now steps the cursor by 50 until three pages in a row add nothing, and skips repeats:
new = 0
for c in data.get("comments") or []:
if c.get("cid") in seen:
continue
seen.add(c.get("cid"))
new += 1
# ...
# On long threads has_more turns false early and a page can come back
# empty mid-list, so keep stepping until three pages in a row add nothing.
empty = 0 if new else empty + 1
cursor += 50
On the NASA video above, --limit 100 returned 100 comments in 1.1 seconds from the datacenter server and 2.8 through the proxy. --limit 5000 returned 1,593 comments in 13.9 seconds from the datacenter server and 1,594 in 34.1 through the proxy; the two connections start at slightly different comments.
The 3,068 the video shows counts replies too, and the script returns a reply count, not the replies. The 1,593 comments report 793 replies between them, 2,386 in all, so 682 of the 3,068 were out of reach. How to get TikTok video comments compares the other ways to get them.
How do you scrape a TikTok hashtag?
Run hashtag <name>; it returns the tag's id, total views and video count, and a short list of its top videos from the embed page tiktok.com/embed/tag/cooking. For #cooking it returned 556,307,836,550 views and 19,362,985 videos in 1.2 seconds from the datacenter server, 2.8 through the proxy, and --limit 5 gave 5 top videos. The embed page caps the list: #fyp returned 10 videos and #gardening 9 with --limit 50.
The script also stops at tags TikTok hides from the embed page. hashtag travel exited with Hashtag not found, or TikTok does not show it on its embed page (it hides some big tags) on 2 of 2 tries, from the datacenter server and through the proxy. TikTok counts 94,893,419 videos for it, and our API read it.
Does TikTok block the script?
Not from our datacenter server on 2026-10-17, though the README says TikTok blocks many datacenter IPs. This function decides, and it raised no block error in any of our runs:
def _get(self, url, what, ok_statuses=(200,)):
try:
resp = self.http.get(url, headers=HEADERS, timeout=30)
except requests.RequestsError as exc:
raise ScrapeError(f"Network error: {exc}") from exc
blocked = (
"TikTok blocked this IP. It does this to datacenter IPs and to some home or residential ones. "
"Run from a home connection, or pass --proxy with a sticky US residential proxy."
)
if "_wafchallengeid" in resp.text[:3000]:
raise ScrapeError(f"{blocked} (It sent its 'Please wait...' challenge page instead of {what}.)")
if resp.status_code in (403, 429, 503):
raise ScrapeError(f"{blocked} (HTTP {resp.status_code} for {what}.)")
# ...
We ran every command from our datacenter server with no proxy and through a residential proxy with a US exit. Our test was one server and one proxy provider.
| Command | Datacenter, no proxy | Residential proxy |
|---|---|---|
profile nasa |
0.8 s | 1.9 s |
profile x30 |
14.5 s, 30 profiles | 34.0 s, 30 profiles |
videos nasa --details |
6.6 s, 11 videos | 16.0 s, 11 videos |
comments --limit 100 |
1.1 s, 100 comments | 2.8 s, 100 comments |
comments --limit 5000 |
13.9 s, 1,593 comments | 34.1 s, 1,594 comments |
hashtag cooking |
1.2 s, 5 videos | 2.8 s, 5 videos |
hashtag travel |
refused | refused |
Of 22 commands from the datacenter server, 18 returned data. The four errors were a made-up user, a made-up video id, comments on that id, and the hashtag travel; none was a block. The proxy was 1.4 to 4.4 times slower on every command we ran both ways, and its cost is yours.
What the script can't do, and the API calls that do
The free script cannot page a hashtag's videos past the top 10, read the tags TikTok hides from its embed page, or list videos by sound. We make ToolzerHub, and its TikTok API does those with one API key and no proxy of yours. On 2026-10-17 we sent hashtag=gardening to get TikTok's id for the tag, then that id as challenge_id:
curl -H "x-api-key: $TOOLZERHUB_API_KEY" \
"https://api.toolzerhub.com/v1/tiktok/hashtag?hashtag=gardening"
curl -H "x-api-key: $TOOLZERHUB_API_KEY" \
"https://api.toolzerhub.com/v1/tiktok/hashtag_videos?challenge_id=186563&count=30"
The first call returned the tag's id, 186563. The second took 2.9 seconds and returned 30 videos with has_more true. Trimmed to one video:
{
"data": {
"entries": [
{
"id": "7691252206019923213",
"desc": "4 easy diy garden hacks #garden #plant #gardening101 #veggies #harvest ",
"create_time": 1790759220,
"author": { "unique_id": "planthack3", "nickname": "PlantHack", "verified": false },
"stats": { "play_count": 220400, "digg_count": 11800, "comment_count": 108, "share_count": 7414 },
"hashtags": ["garden", "plant", "gardening101", "veggies", "harvest"]
}
],
"count": 30,
"next_cursor": "30",
"has_more": true
}
}
Send each next_cursor back as cursor until has_more is false. This script does that, drops repeated videos, tries a page twice when it gets an HTTP error and writes a CSV; a timeout or dropped connection still stops it. We ran it with its HTTP calls sent through our own command-line client instead of requests.
import csv
import os
import sys
from datetime import datetime, timezone
import requests
BASE = "https://api.toolzerhub.com/v1/tiktok"
HEADERS = {"x-api-key": os.environ.get("TOOLZERHUB_API_KEY", "")}
def get(path, **params):
resp = requests.get(f"{BASE}/{path}", params=params, headers=HEADERS, timeout=60)
resp.raise_for_status()
return resp.json()["data"]
def list_page(**params):
"""One page of a hashtag's videos. Tries twice; None means the page kept failing."""
for attempt in range(2):
try:
return get("hashtag_videos", **params)
except requests.HTTPError:
if attempt:
return None
def main(tag, path):
info = get("hashtag", hashtag=tag) # 1 credit: TikTok's numeric id for the tag
if not info:
sys.exit(f"No such hashtag: {tag}")
videos, rows, cursor, calls = {}, 0, 0, 1
while True:
page = list_page(challenge_id=info["id"], count=30, cursor=cursor) # 1 credit a page
if page is None:
print("A page kept failing; saving what we have.")
break
calls += 1
for v in page["entries"]:
rows += 1
videos.setdefault(v["id"], v) # pages can repeat a video
if not page["has_more"]:
break
cursor = page["next_cursor"]
with open(path, "w", newline="", encoding="utf-8") as f:
out = csv.writer(f)
out.writerow(["id", "username", "created_at", "plays", "likes", "comments", "shares", "caption"])
for v in videos.values():
s = v["stats"]
created = datetime.fromtimestamp(v["create_time"], timezone.utc).date().isoformat()
out.writerow([v["id"], v["author"]["unique_id"], created, s["play_count"], s["digg_count"],
s["comment_count"], s["share_count"], v["desc"]])
print(f"#{tag}: {len(videos)} videos ({rows - len(videos)} repeats dropped) in {calls} calls -> {path}")
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2])
export TOOLZERHUB_API_KEY=your_key
python hashtag_videos.py cooking cooking.csv
Output on 2026-10-17, after 16.6 seconds (its "calls" count the tag lookup plus 5 pages):
#cooking: 141 videos (7 repeats dropped) in 6 calls -> cooking.csv
We walked 8 tags 12 times. Nine walks ended with has_more false after 118 to 188 entries (109 to 174 unique), in 4 to 7 pages. #cooking alone gave 118 to 145 entries across runs, so expect a different count each time. That is far past the script's 10, but not the whole tag: #gardening holds 4,370,345 videos by TikTok's count and its list stopped near 115.
Repeats ran 103 of 1,310 entries, so deduplicate. Three walks hit an HTTP 500 partway ("no itemList in an otherwise valid envelope") after 118, 89 and 30 entries. The script's retry fixed two such pages in one #fyp run.
The tag travel, which the script refuses, returned a list of 188 entries (174 unique) in 7 pages. music_videos takes the music_id that the script's video prints and lists videos that use that sound; the three sounds we paged returned 6, 7 and 13 videos.
The script is the better pick for single records. Our profile route returned NASA's followers as 2,100,000 where the script read about 2,097,000, and our video route has no save count, hashtags or upload time.
What the API costs
A free key gives you 100 credits a month with no card: get one here. Every TikTok call above is 1 credit, and failed calls are not charged. 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. The #cooking run took 6 credits for 141 videos: $0.006 to $0.009.
For hashtag_videos, the 7 days to 2026-10-17 had 41 successful calls and 1 server error, none a 200 with an empty body (under 40 bytes). Ten live calls on each of the five routes all returned data. Calls are slow at the tail: in our walks the median was 3.2 seconds, 5 of 59 calls took over 10 and the slowest took 33.7, so set a 60-second timeout.
Is it legal to scrape TikTok?
TikTok's terms forbid it. The US Terms of Service (last updated July 15, 2026) say you must not "scrape, crawl, export or otherwise extract any data or content in any form, for any purpose, from the Platform using any automated system or software, including automated “bots,” except as approved in writing by TikTok USDS Joint Venture".
The terms for the rest of the world (last updated 1 December 2025) say you may not "use automated scripts to collect information from or otherwise interact with the Services". The script reads only what a visitor without an account sees, and neither clause makes an exception for that. We did not read the EEA, UK, Swiss or Indian versions. This is not legal advice.
TikTok's own APIs are the supported route; what TikTok's official API allows covers who can use them. The same approach for Threads is in scraping Threads with Python.
We are not affiliated with TikTok Ltd. or ByteDance Ltd.
Sources
- toolzerhub/tiktok-scraper, source and README (commits 60f6baa and b339fa7), https://github.com/toolzerhub/tiktok-scraper, run 2026-10-17
- TikTok Terms of Service, US (last updated July 15, 2026), https://www.tiktok.com/legal/page/us/terms-of-service/en, read 2026-10-17
- TikTok Terms of Service, rest of world (last updated 1 December 2025), https://www.tiktok.com/legal/page/row/terms-of-service/en, 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