Guide · Python

Export TikTok comments and replies to CSV with Python

Give the script a video URL and it walks every page of comments, fetches the replies under the comments that have them, and writes the whole thread to one CSV.

ScrapingBot 8 min read
Endpoints
/comment/list, /comment/reply
Cost
1 credit per page
Language
Python 3.9+
Needs
requests
On this page

The comments under a video are the closest thing TikTok has to a focus group: questions people want answered, complaints, requests, the jokes that tell you how a post landed. Reading them in the app means scrolling and tapping "View replies" one thread at a time. A CSV lets you sort, filter, count and hand the text to whatever analysis you like.

Comments come from two endpoints. /comment/list returns the top-level comments on a video, a page at a time. /comment/reply returns the replies under one comment. This guide combines them into a single flat file, and skips the replies call for every comment that has none. The example video is a NASA recap from October 2, 2026 with 126 comments.

Before you start

  • A ScrapingBot API key. Create a free account for 100 credits, no card needed.
  • Python 3.9 or newer and pip install requests.
  • The URL of a public video, such as https://www.tiktok.com/@nasa/video/7692161454694206733.

One page of comments

/comment/list takes the video URL and a page size from 1 to 50:

curl -X POST https://scrapingbot.io/api/v1/tiktok \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"endpoint": "/comment/list", "params": {"url": "https://www.tiktok.com/@nasa/video/7692161454694206733", "count": 50}}'

The first page for the NASA video, with one comment shown and its author trimmed:

{
  "success": true,
  "data": {
    "total": 126,
    "cursor": 10,
    "hasMore": true,
    "comments": [
      {
        "id": "7692163746915566368",
        "text": "We want Artemis 4 so bad!!",
        "video_id": "7692161454694206733",
        "digg_count": 38,
        "reply_total": 2,
        "create_time": 1790971455,
        "status": 1,
        "images": [],
        "user": {
          "id": "6785840000720176133",
          "unique_id": "christianvale007",
          "nickname": "Christian valentini",
          …
        }
      },
      …
    ]
  },
  "duration": "0.26",
  "statusCode": 200,
  "creditsUsed": 1,
  "processed_time": 0.1922
}

total is the number of comments TikTok counts on the video. digg_count is likes on the comment, reply_total is how many replies it has, and create_time is a Unix timestamp. This page held 9 comments while cursor moved to 10, which is why the script never computes the next cursor itself: it sends back exactly what the response gave it, and stops only when hasMore is false.

Replies to a comment

The first comment above has reply_total: 2. To read those replies, pass its id as comment_id, together with the video_id (or the video URL as url):

curl -X POST https://scrapingbot.io/api/v1/tiktok \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"endpoint": "/comment/reply", "params": {"video_id": "7692161454694206733", "comment_id": "7692163746915566368"}}'
{
  "success": true,
  "data": {
    "comments": [
      {
        "id": "7692165300930315021",
        "text": "Bro we haven’t even had Artemis III yet",
        "digg_count": 6,
        "create_time": 1790971850,
        "video_id": "7692161454694206733",
        "user": { "unique_id": "zx6paul", "nickname": "Zx6Paul" }
      }
    ],
    "cursor": 2,
    "hasMore": false,
    "total": 2
  },
  "processed_time": 0.982,
  "duration": "1.05",
  "statusCode": 200,
  "creditsUsed": 1
}

Replies come back inside data.comments with the same fields as comments, minus reply_total. Notice that total says 2 but one reply came back and hasMore is false. The totals can be higher than what you get back, so treat them as an upper bound. A script that keeps asking until it has collected total replies would keep requesting pages that do not exist; one that follows hasMore finishes.

On this first page, 3 of the 9 comments had replies. Checking reply_total first saves a call (and a credit) for each of the other six.

One flat file, with a parent_id column

Nested JSON is awkward in a spreadsheet, so the script writes replies as ordinary rows directly under their parent comment, with a parent_id column that links them. Top-level comments have an empty parent_id. Filter on that column to see only top-level comments, or group by it to count the length of each thread.

ColumnFromNotes
comment_ididUnique per comment or reply.
parent_idthe comment you asked aboutEmpty for top-level comments.
video_idvideo_idHandy when you merge exports from several videos.
authoruser.unique_idThe commenter's handle.
postedcreate_timeConverted to ISO 8601 in UTC.
likesdigg_countLikes on the comment.
reply_totalreply_totalTop-level comments only; empty on replies.
texttextThe comment, emoji included.

The script

Save this as comments_to_csv.py. One generator, pages, handles the cursor for both endpoints, since they page the same way.

import csv
import sys
import time
from datetime import datetime, timezone

import requests

API = "https://scrapingbot.io/api/v1/tiktok"
HEADERS = {"x-api-key": "YOUR_API_KEY"}


def call(endpoint, params, tries=3):
    """One API call. Failed calls are refunded, so a retry costs nothing extra."""
    for attempt in range(tries):
        res = requests.post(API, headers=HEADERS, timeout=60,
                            json={"endpoint": endpoint, "params": params})
        body = res.json()
        if body.get("success"):
            return body["data"]
        if res.status_code in (400, 401, 402):  # bad input, bad key, no credits
            raise RuntimeError(body.get("error"))
        time.sleep(2 ** attempt)  # 408, 429 or 5xx: wait and try again
    raise RuntimeError(f"{endpoint} kept failing: {body.get('error')}")


def pages(endpoint, params, max_pages):
    """Yield one page at a time, following the cursor until hasMore is false."""
    cursor = None
    for _ in range(max_pages):
        page_params = {**params, "count": 50}
        if cursor is not None:
            page_params["cursor"] = cursor
        data = call(endpoint, page_params)
        yield data
        if not data.get("hasMore"):
            return
        cursor = data["cursor"]


FIELDS = ["comment_id", "parent_id", "video_id", "author", "posted", "likes", "reply_total", "text"]


def row(c, parent_id=""):
    return {
        "comment_id": c["id"],
        "parent_id": parent_id,  # empty for top-level comments
        "video_id": c["video_id"],
        "author": c["user"]["unique_id"],
        "posted": datetime.fromtimestamp(c["create_time"], timezone.utc).isoformat(),
        "likes": c.get("digg_count", 0),
        "reply_total": c.get("reply_total", ""),
        "text": c["text"],
    }


def export(video_url, max_pages=20):
    rows, seen = [], set()
    for n, data in enumerate(pages("/comment/list", {"url": video_url}, max_pages), 1):
        print(f"page {n}: {len(data['comments'])} comments "
              f"({data.get('total')} on the video), hasMore={data.get('hasMore')}")
        for c in data["comments"]:
            if c["id"] in seen:  # pages can overlap
                continue
            seen.add(c["id"])
            rows.append(row(c))
            if not c.get("reply_total"):  # no replies: skip the extra call
                continue
            replies = [r for page in pages("/comment/reply",
                                           {"video_id": c["video_id"], "comment_id": c["id"]},
                                           max_pages)
                       for r in page["comments"]]
            print(f"  {c['id']}: {len(replies)} of {c['reply_total']} replies")
            for r in replies:
                if r["id"] not in seen:
                    seen.add(r["id"])
                    rows.append(row(r, parent_id=c["id"]))
    return rows


if __name__ == "__main__":
    url = sys.argv[1] if len(sys.argv) > 1 else "https://www.tiktok.com/@nasa/video/7692161454694206733"
    max_pages = int(sys.argv[2]) if len(sys.argv) > 2 else 20
    rows = export(url, max_pages)
    path = f"comments_{url.rstrip('/').split('/')[-1]}.csv"
    with open(path, "w", newline="", encoding="utf-8-sig") as f:  # -sig: Excel reads emoji
        writer = csv.DictWriter(f, fieldnames=FIELDS)
        writer.writeheader()
        writer.writerows(rows)
    print(f"saved {len(rows)} rows to {path}")
  • Replies are fetched only when reply_total is above 0. That single check is most of the cost saving.
  • Every row is de-duplicated on its ID. Pages can overlap, and a reply should land under one parent only once.
  • max_pages caps each list: the comment list and each reply thread. The default of 20 pages of up to 50 leaves room for up to 1,000 comments, fewer when pages come back short; raise it for very large videos. The cap means a run can never cost more than you planned.
  • The file is written as utf-8-sig. TikTok comments are full of emoji, and the byte-order mark is what makes Excel show them correctly instead of garbled characters.
  • Retries are selective. A 400, 401 or 402 fails the same way every time and is raised. Timeouts, 429 and server errors are retried after a pause. Failed calls are refunded.

Run it

The second argument is the page cap. Start with 1 to check the output for a single credit plus the replies on that page:

python comments_to_csv.py https://www.tiktok.com/@nasa/video/7692161454694206733 1
page 1: 9 comments (126 on the video), hasMore=True
  7692163746915566368: 1 of 2 replies
…

The script prints one line per comment that has replies, then the row count and the file name, comments_7692161454694206733.csv. The first rows of that file, with the reply sitting directly under its parent:

comment_id,parent_id,video_id,author,posted,likes,reply_total,text
7692163746915566368,,7692161454694206733,christianvale007,2026-10-02T20:04:15+00:00,38,2,We want Artemis 4 so bad!!
7692165300930315021,7692163746915566368,7692161454694206733,zx6paul,2026-10-02T20:10:50+00:00,6,,Bro we haven’t even had Artemis III yet
7692351231586485006,,7692161454694206733,cass_cyto,2026-10-03T08:13:00+00:00,0,0,Seti deserves love too they just finished raising the funds for MARBLE 🥺🥺🥺
7692389189433901845,,7692161454694206733,ntabiso.moyo92,2026-10-03T10:40:53+00:00,1,0,iwant to be a Astronaut
…

When that looks right, drop the cap argument to export the whole video. Each comment page is 1 credit, and each comment with replies adds at least 1 more.

Reading the export

A few things that work well once the file is open:

  • Sort by likes with parent_id empty to see which reactions the audience agreed with most. On the NASA video, the most-liked comment on the first page asked for Artemis 4, and its one returned reply pointed out that Artemis III has not flown yet.
  • Filter text for a question mark to pull out the questions people asked. They are often the best source of follow-up content or FAQ entries.
  • Sort by posted to see how the conversation moved after the video went up, for example whether early comments were about the topic and later ones about something else.
  • Append exports from several videos into one sheet. The video_id column keeps them apart, so you can compare how the same audience reacted to different posts.

The text column is also ready to hand to a sentiment model or an LLM for summarizing. Keep comment_id in what you send, so every claim in the summary can be traced back to the comment it came from.

What an export costs

For a video like this one, with 126 comments, the comment list is one call per page: about 13 calls if pages come back at around 10 comments like the one above. Add one replies call per comment that has replies. Expect a few dozen credits, within the 100 free credits. Bigger videos scale with how many threads they have: the lo-fi clip in the sound export guide has 740 comments, and the first comment on its first page has 16 replies.

On the Starter plan ($49.99 for 275,000 credits) a credit is under two hundredths of a cent, so even exporting the comments of hundreds of videos a month costs little. For many videos, run several exports in parallel, up to your plan's concurrency limit.

Where to go from here

Common questions

How do I scrape TikTok comments with Python?

POST {"endpoint": "/comment/list", "params": {"url": "<video URL>", "count": 50}} to https://scrapingbot.io/api/v1/tiktok with your key in x-api-key, then send the returned cursor back until hasMore is false. The script in this guide does that and writes a CSV.

Does the API return replies to comments?

Yes, through a second endpoint. /comment/reply takes the parent comment's id as comment_id and the video_id, and pages the same way. Only comments with reply_total above 0 have replies, so call it for those alone.

Why does my export have fewer comments than the video shows?

The totals TikTok reports can be higher than the number of comments or replies that come back. In the example here a comment with reply_total 2 returned one reply. Treat the totals as an upper bound, not a checksum, and stop paging on hasMore.

Why does the CSV look garbled in Excel?

Excel guesses the encoding of CSV files and often gets emoji and accented letters wrong. Writing the file as utf-8-sig, which adds a byte-order mark, tells Excel it is UTF-8. Google Sheets and pandas read it either way.

How much does exporting comments cost?

One credit per page of comments plus one per page of replies, and only comments that have replies need a replies call. A video with a few hundred comments is typically a few dozen credits. Failed calls are refunded.

Start scraping in the next five minutes.

100 free credits, no credit card. One API key works for websites, TikTok, Instagram, Google, Amazon and ChatGPT.