DEV Community

Coco
Coco

Posted on

How to scrape TikTok in 2026?

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

  1. A free Chocodata API key. Sign up, confirm the email, copy the key from the dashboard. Free, no card.

Copying a free API key from the dashboard

  1. Python 3.9+ and requests.
python -m venv .venv && source .venv/bin/activate
pip install requests
Enter fullscreen mode Exit fullscreen mode
  1. 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"])
Enter fullscreen mode Exit fullscreen mode
13 fields
@nasa | NASA | verified=True
Making the seemingly impossible, possible.✨
Enter fullscreen mode Exit fullscreen mode

HTTP 200 in 1.07s, 13 top-level fields.

Terminal showing the profile request returning 13 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:,}")
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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.

Both stats blocks printed showing rounded and exact counts

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))
Enter fullscreen mode Exit fullscreen mode
{'ts': '2026-07-27T13:11:42+00:00', 'username': 'nasa', 'followers': 194286, 'hearts': 745186, 'videos': 15}
Enter fullscreen mode Exit fullscreen mode

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.

CSV row appended with the timestamped snapshot

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]))
Enter fullscreen mode Exit fullscreen mode
30 on this page | has_more=True | total=194,289
fields per follower: 11
Enter fullscreen mode Exit fullscreen mode

HTTP 200 in 1.39s. Thirty entries per page against a reported total of 194,289, with followerCount and followingCount on each entry.

Terminal showing one follower page and the reported total

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")
Enter fullscreen mode Exit fullscreen mode
60 sampled | 60 unique
Enter fullscreen mode Exit fullscreen mode

Sixty entries and sixty distinct accounts, so the cursor advances with no overlap between pages.

Two follower pages sampled with no duplicate accounts

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))
Enter fullscreen mode Exit fullscreen mode
{'sample': 60, 'median_followers': 150.5, 'median_following': 468.5, 'verified': 0,
 'with_bio': 33, 'buckets': {'100-9999': 37, '<100': 23}}
Enter fullscreen mode Exit fullscreen mode

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.

Follower sample reduced to buckets and medians

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"])
Enter fullscreen mode Exit fullscreen mode
17 fields | video | video
Scramble up ur name & I’ll try to guess it😍❤️ #foryoupage #petsoftiktok #aesthetic
Scout, Suki & Stella | @scout2015
Enter fullscreen mode Exit fullscreen mode

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.

Terminal showing the video metadata request returning 17 fields

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)
Enter fullscreen mode Exit fullscreen mode
caption: Scramble up ur name & Ill try to guess it😍❤️
hashtags: ['#foryoupage', '#petsoftiktok', '#aesthetic']
Enter fullscreen mode Exit fullscreen mode

Caption and hashtags split out of the title field

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)
Enter fullscreen mode Exit fullscreen mode
{'id': '6718335390845095173', 'author': 'scout2015', 'caption': 'Scramble up ur name & I’ll try to guess ',
 'tags': '#foryoupage #petsoftiktok #aesthetic', 'thumb': '576x1024', 'embed_chars': 957}
Enter fullscreen mode Exit fullscreen mode

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.

Video rows collected with thumbnail size and embed length

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'
Enter fullscreen mode Exit fullscreen mode

statsV2 values are strings. Comparing them without a cast raises:

TypeError: '>' not supported between instances of 'str' and 'int'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)