Three TikTok scraping jobs in Python: a profile with its stats, the aggregate shape of an account's audience, and metadata for a single video. Every request below ran against the Chocodata API on 27 July 2026 with Python 3.13.7 and requests 2.34.2, and every output block is what my terminal actually printed.
Why is it hard to scrape TikTok?
TikTok renders profile and video pages from JavaScript, so the HTML a plain client downloads holds no follower count and no caption, and its internal endpoints expect signed parameters that are generated in the browser and expire. The layer that wastes the most time is fingerprinting, because TLS and device signals are scored ahead of your headers, so a datacenter IP with a convincing User-Agent still lands on a verification wall instead of the data.
Prerequisites
- A free Chocodata API key. Sign up, confirm the email, copy the key from the dashboard. Free, no card.
-
Python 3.9+ and
requests.
python -m venv .venv && source .venv/bin/activate
pip install requests
- The TikTok URL you want to scrape, copied from the address bar.
Tested with Python 3.13.7 and requests 2.34.2 in July 2026.
How to scrape TikTok profiles?
Scraping a TikTok profile takes the profile URL and returns identity plus the follower, like and video totals in one call.
1. GET the profile endpoint with the profile URL
Pass the profile URL as url and read the fields off the top level, because there is no envelope around them.
import time
import requests
BASE = "https://api.chocodata.com/api/v1"
API_KEY = "YOUR_API_KEY"
PROFILE_URL = "https://www.tiktok.com/@nasa"
def get_json(path, tries=3, **params):
"""One GET, retried, so a batch run survives a dropped response."""
params["api_key"] = API_KEY
for attempt in range(tries):
r = requests.get(f"{BASE}/{path}", params=params, timeout=60)
if r.ok:
return r.json()
time.sleep(2)
r.raise_for_status()
profile = get_json("tiktok/profile", url=PROFILE_URL)
print(len(profile), "fields")
print(f"@{profile['uniqueId']} | {profile['nickname']} | verified={profile['verified']}")
print(profile["signature"])
13 fields
@nasa | NASA | verified=True
Making the seemingly impossible, possible.✨
HTTP 200 in 1.07s, 13 top-level fields.
2. Read statsV2 for the exact counts
The profile returns two stats objects, and statsV2 is the one carrying exact numbers rather than display-rounded ones.
print("stats :", profile["stats"])
print("statsV2:", profile["statsV2"])
exact = int(profile["statsV2"]["followerCount"])
print(f"rounded {profile['stats']['followerCount']:,} vs exact {exact:,}")
stats : {'followerCount': 194300, 'followingCount': 20, 'heartCount': 745200, 'videoCount': 15}
statsV2: {'followerCount': '194286', 'followingCount': '20', 'heartCount': '745186', 'videoCount': '15'}
rounded 194,300 vs exact 194,286
A 14-follower gap here, and it scales with account size. The statsV2 values are strings, so cast them at the parse boundary rather than at every comparison.
3. Append a timestamped row to CSV
Write one dated row per run, since the absolute count means little and the delta between runs is the whole point.
import csv
from datetime import datetime, timezone
def snapshot(profile, path="tiktok_profile_history.csv"):
row = {"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"username": profile["uniqueId"],
"followers": int(profile["statsV2"]["followerCount"]),
"hearts": int(profile["statsV2"]["heartCount"]),
"videos": int(profile["statsV2"]["videoCount"])}
with open(path, "a", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=list(row))
if f.tell() == 0:
w.writeheader()
w.writerow(row)
return row
print(snapshot(profile))
{'ts': '2026-07-27T13:11:42+00:00', 'username': 'nasa', 'followers': 194286, 'hearts': 745186, 'videos': 15}
Counts move between calls, so a row without a timestamp cannot be diffed later. Who those followers are is a separate endpoint, and it is best treated as an aggregate.
How to scrape TikTok followers?
Scraping TikTok followers returns pages of the public follower list, and the output worth keeping is the aggregate shape of the audience rather than a roster of accounts.
1. Fetch the first follower page
Send the username and the response returns one page plus the handles for continuing.
page = get_json("tiktok/follower", username="nasa")
print(f"{page['followers_count']} on this page | has_more={page['has_more']} "
f"| total={page['total']:,}")
print("fields per follower:", len(page["followers"][0]))
30 on this page | has_more=True | total=194,289
fields per follower: 11
HTTP 200 in 1.39s. Thirty entries per page against a reported total of 194,289, with followerCount and followingCount on each entry.
2. Follow next_cursor while has_more is true
Loop on next_cursor with a page cap, because an uncapped loop against a six-figure follower list runs for hours and collects far more than any aggregate needs.
import time
def sample_followers(username, pages=2, delay=1.0):
cursor, users = None, []
for _ in range(pages):
params = {"username": username}
if cursor:
params["cursor"] = cursor
data = get_json("tiktok/follower", **params)
users.extend(data["followers"])
cursor = data["next_cursor"]
if not data["has_more"] or not cursor:
break
time.sleep(delay)
return users
users = sample_followers("nasa", pages=2)
print(len(users), "sampled |", len({u["uniqueId"] for u in users}), "unique")
60 sampled | 60 unique
Sixty entries and sixty distinct accounts, so the cursor advances with no overlap between pages.
3. Reduce to aggregates and drop the identity fields
Collapse the sample to counts in the same function that collects it, so nothing downstream ever holds per-account rows.
import statistics
from collections import Counter
def audience(users):
sizes = [u["followerCount"] for u in users]
buckets = Counter("10k+" if c >= 10000 else "100-9999" if c >= 100 else "<100"
for c in sizes)
return {"sample": len(sizes),
"median_followers": statistics.median(sizes),
"median_following": statistics.median(u["followingCount"] for u in users),
"verified": sum(1 for u in users if u["verified"]),
"with_bio": sum(1 for u in users if u["signature"]),
"buckets": dict(buckets)}
print(audience(users))
{'sample': 60, 'median_followers': 150.5, 'median_following': 468.5, 'verified': 0,
'with_bio': 33, 'buckets': {'100-9999': 37, '<100': 23}}
Thirty-seven accounts in the 100 to 9,999 band against 23 below 100, a median of 150.5 followers, and no verified accounts in the sample. That reads as a consumer audience rather than an industry one, and it is the whole answer without keeping a single row about a person. Individual videos are the other half of the picture.
How to scrape TikTok video metadata?
Scraping TikTok video metadata takes the video URL and returns the caption, creator, thumbnail and embed markup for that clip.
1. Call oembed with the video URL
Pass the video URL and the metadata comes back in a single request.
VIDEO_URL = "https://www.tiktok.com/@scout2015/video/6718335390845095173"
video = get_json("tiktok/oembed", url=VIDEO_URL)
print(len(video), "fields |", video["type"], "|", video["embed_type"])
print(video["title"])
print(video["author_name"], "| @" + video["author_unique_id"])
17 fields | video | video
Scramble up ur name & I’ll try to guess it😍❤️ #foryoupage #petsoftiktok #aesthetic
Scout, Suki & Stella | @scout2015
HTTP 200 in 2.07s with 17 fields. title holds the caption, and type confirms the URL resolved to a video rather than a creator page.
2. Parse hashtags out of the title
Split title into caption text and hashtags, because those two go into different columns in every downstream job.
import re
def split_caption(title):
tags = re.findall(r"#\w+", title)
return re.sub(r"\s*#\w+", "", title).strip(), tags
caption, tags = split_caption(video["title"])
print("caption:", caption)
print("hashtags:", tags)
caption: Scramble up ur name & I’ll try to guess it😍❤️
hashtags: ['#foryoupage', '#petsoftiktok', '#aesthetic']
3. Batch a URL list with error handling
Wrap the call so one unreachable URL does not end the run, and collect rows rather than raw payloads.
def video_rows(urls, delay=1.0):
rows = []
for u in urls:
try:
d = get_json("tiktok/oembed", url=u)
except requests.HTTPError as e:
print(f"[skip] {u} -> {e.response.status_code}")
continue
caption, tags = split_caption(d["title"])
rows.append({"id": d["id"], "author": d["author_unique_id"],
"caption": caption[:40], "tags": " ".join(tags),
"thumb": f"{d['thumbnail_width']}x{d['thumbnail_height']}",
"embed_chars": len(d["html"])})
time.sleep(delay)
return rows
for row in video_rows([VIDEO_URL]):
print(row)
{'id': '6718335390845095173', 'author': 'scout2015', 'caption': 'Scramble up ur name & I’ll try to guess ',
'tags': '#foryoupage #petsoftiktok #aesthetic', 'thumb': '576x1024', 'embed_chars': 957}
A 576 by 1024 thumbnail and 957 characters of embed markup per video. Download the thumbnail bytes rather than storing its URL, for the reason in the next section.
The part that breaks
There is no data envelope. Fields sit at the top level, so the reflex from other APIs fails on the first line:
KeyError: 'data'
statsV2 values are strings. Comparing them without a cast raises:
TypeError: '>' not supported between instances of 'str' and 'int'
And stats is rounded, so 194,300 there against 194,286 in statsV2. Cast statsV2 with int() once and never read stats.
oEmbed carries no playback metrics. The stats field on that response is null, so reaching into it raises:
TypeError: 'NoneType' object is not subscriptable
Treat oEmbed as the identity and embed layer. Caption, creator, thumbnail and embed HTML are what it returns.
Media URLs are signed and expire. The avatar URL I pulled carried an expiry 47.8 hours out and the thumbnail URL is signed the same way, so links stored in a database go dead within days. Mirror the image bytes at collection time.
Full script
"""Scrape TikTok profiles, follower audience shape and video metadata.
Python 3.9+, requests. Tested 27 July 2026 with 3.13.7 / requests 2.34.2.
"""
import csv
import re
import statistics
import time
from collections import Counter
from datetime import datetime, timezone
import requests
BASE = "https://api.chocodata.com/api/v1"
API_KEY = "YOUR_API_KEY"
def get_json(path, tries=3, **params):
"""One GET, retried, so a batch run survives a dropped response."""
params["api_key"] = API_KEY
for attempt in range(tries):
r = requests.get(f"{BASE}/{path}", params=params, timeout=60)
if r.ok:
return r.json()
time.sleep(2)
r.raise_for_status()
def snapshot_profile(profile_url, path="tiktok_profile_history.csv"):
p = get_json("tiktok/profile", url=profile_url)
row = {"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"username": p["uniqueId"],
"followers": int(p["statsV2"]["followerCount"]),
"hearts": int(p["statsV2"]["heartCount"]),
"videos": int(p["statsV2"]["videoCount"])}
with open(path, "a", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=list(row))
if f.tell() == 0:
w.writeheader()
w.writerow(row)
return row
def audience_profile(username, pages=2, delay=1.0):
cursor, users = None, []
for _ in range(pages):
params = {"username": username}
if cursor:
params["cursor"] = cursor
data = get_json("tiktok/follower", **params)
users.extend(data["followers"])
cursor = data["next_cursor"]
if not data["has_more"] or not cursor:
break
time.sleep(delay)
sizes = [u["followerCount"] for u in users]
buckets = Counter("10k+" if c >= 10000 else "100-9999" if c >= 100 else "<100"
for c in sizes)
return {"sample": len(sizes),
"median_followers": statistics.median(sizes),
"median_following": statistics.median(u["followingCount"] for u in users),
"verified": sum(1 for u in users if u["verified"]),
"with_bio": sum(1 for u in users if u["signature"]),
"buckets": dict(buckets)}
def split_caption(title):
tags = re.findall(r"#\w+", title)
return re.sub(r"\s*#\w+", "", title).strip(), tags
def video_rows(urls, delay=1.0):
rows = []
for u in urls:
try:
d = get_json("tiktok/oembed", url=u)
except requests.HTTPError as e:
print(f"[skip] {u} -> {e.response.status_code}")
continue
caption, tags = split_caption(d["title"])
rows.append({"id": d["id"], "author": d["author_unique_id"],
"caption": caption[:40], "tags": " ".join(tags),
"thumb": f"{d['thumbnail_width']}x{d['thumbnail_height']}",
"embed_chars": len(d["html"])})
time.sleep(delay)
return rows
if __name__ == "__main__":
print(snapshot_profile("https://www.tiktok.com/@nasa"))
print(audience_profile("nasa", pages=2))
for row in video_rows(["https://www.tiktok.com/@scout2015/video/6718335390845095173"]):
print(row)
Summary
One request each covers the three jobs: a profile URL returns 13 fields with exact totals in about a second, a username returns 30 followers per page with a cursor that reaches 60 unique accounts across two calls, and a video URL returns 17 fields of caption, creator, thumbnail and embed markup. Any language with an HTTP client works the same way, so Node, Go and Ruby need no special handling. The habit to build in from the start is casting statsV2 and ignoring stats, because the rounded block silently merges accounts that differ by hundreds of followers.
FAQ
Can I get a TikTok video's view count from the oEmbed response?
No, that response carries the caption, creator, thumbnail and embed HTML rather than playback metrics.
Why do TikTok avatar and thumbnail URLs stop working after a while?
They are signed with an expiry, and the avatar I pulled expired 47.8 hours after the request, so mirror the image instead of storing the link.
Is scraping TikTok legal?
Public data is not automatically illegal to collect, but TikTok's terms restrict automated access, which makes it a contract question rather than a criminal one, and this is not legal advice.










Top comments (0)