# TikTok Video Stats: Views, Likes, Comments, Shares by URL (`sergeyfaraday/tiktok-video-stats`) Actor

Fresh views, likes, comments, shares and saves for a list of TikTok videos, read from the public video page. No login, no browser, no proxy. Deleted and private videos get their own status. Built for repeat measurements.

- **URL**: https://apify.com/sergeyfaraday/tiktok-video-stats.md
- **Developed by:** [Sergey Faraday](https://apify.com/sergeyfaraday) (community)
- **Categories:** Social media, Videos
- **Stats:** 2 total users, 0 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

Pay per usage

This Actor is paid per platform usage. The Actor is free to use, and you only pay for the Apify platform usage, which gets cheaper the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-usage

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## TikTok Video Stats: Views, Likes, Comments, Shares by URL

Measures known TikTok videos again and again: views, likes, comments, shares and saves, each time with the moment of measurement. Give it a list of video URLs and compare snapshots to see how each video grows.

It reads the public video page with plain HTTP requests. No login, no cookies, no browser and no proxy, so it costs a fraction of a browser-based scraper.

**Not collected here:** search results, hashtag and sound video lists. TikTok does not serve them openly. You can get them from the companion *Unified Scraper Adapter* actor (modules `tiktok_search`, `tiktok_sound`).

### How to use

1. Paste video URLs or ids into `videoUrls`. For a panel measured over time use numeric ids: resolve short links first, because a dead short link has no numeric id to join with earlier rows (`sampling.requestedAs` is the only link). `requestedAs` holds the form you asked in without its query string and without the author handle (`https://www.tiktok.com/@_/video/<id>`). `sampling.requestedAs`, `data.requestedAs`, `applied.controlVideo.requestedAs` and invalid inputs in `sources[].excluded` keep origin and path only: the query string and fragment of a share link are never stored, and a handle in the path of your URL is replaced by `@_`.
2. Give each run a `snapshotId` (for example the date) and run it on a schedule. Always pass it: without it each run gets `<actor>.<runId>`, so a retry becomes a new snapshot.
3. Compare snapshots by `sourceRecordId`, as in the next section. The actor does not compare by itself: one run cannot read the rows of another.

#### Comparing two snapshots

| Case | How to tell |
|---|---|
| new | no earlier row for this `sourceRecordId` |
| unchanged | same `provenance.contentChecksum`. It covers saves, which are not rounded, so it also catches growth the rounded counters hide |
| changed | different `provenance.contentChecksum` |
| gone | a `deleted`, `not_public` or `not_found` row where the earlier snapshot had a post. `blocked`, `fetch_error` and `not_attempted` rows say nothing about the video |
| below resolution | a difference smaller than `precision.step`: this is not zero growth, and a rounded counter can stay the same while the video grows |

State rows take `canonicalUrl` from the id (`https://www.tiktok.com/@_/video/<id>`), so the form of the input does not read as a change. A short link stays its own key: before the read it cannot be tied to a video, so a short link against a full URL is another task. A change of the asked proxy country reads as changed through `source.marketReason`, not in the metrics; `data.requestRegion` names the country and stays out of the checksum.

Do not assume counters only grow: comments fell between readings in stored data, sometimes by more than the rounding step.

### Input

| Field | Default | Meaning |
|---|---|---|
| `videoUrls` | | video URLs, short links (`vm.tiktok.com`) or numeric video ids. `0`, any short number and a short link host with no code in the path are invalid inputs: no request, listed in `sources[].excluded` with reason `not_a_tiktok_video_reference`, and the run status does not move |
| `delayMs` | 1500 | pause between two videos |
| `stopAfterBlocks` | 5 | stop after this many blocked videos in a row |
| `controlVideoUrl` | | a video you know is live; see "Is not_found reliable?" below |
| `snapshotId` | `<actor>.<runId>` | your id for this measurement. One object has one row per snapshot: a measurement replaces a state row of the same video (their `recordKey`s differ by `recordType`, so join on `sourceRecordId`), a later run does not change a finished snapshot, and a value that differs is kept apart. A new id gives a new observation. Compare snapshots by `provenance.contentChecksum` |
| `maxItems`, `maxRunSeconds` | | limits. `maxItems` is a hard cap on rows: the ids past it get no row, they are listed in the `MANIFEST` under `sources[].applied.notAttemptedIds` and `sources[].excluded` with reason `not_attempted:maxItems` (`excluded` holds the first 200 ids, plus a count). A time cut comes back as `not_attempted` rows while `maxItems` allows. The `maxRunSeconds` budget counts from the run start and a restart does not renew it, the time between workers counts. A resurrected run past its budget stops at once and finishes `partial`; a new run under the same `snapshotId` within 24 hours completes the snapshot |

```json
{ "videoUrls": ["https://www.tiktok.com/@tiktok/video/7106594312292453675", "7534061113365859586"], "snapshotId": "2026-11-02" }
```

### Output

One `post` row per live video:

```json
{
  "recordType": "post",
  "platform": "tiktok",
  "sourceRecordId": "7534061113365859586",
  "canonicalUrl": "https://www.tiktok.com/@user/video/7534061113365859586",
  "time": { "publishedAt": "2025-08-02T18:45:03.000Z", "observedAt": "2026-11-02T14:34:30.000Z", "observedAtBasis": "response_received" },
  "metrics": {
    "views": { "value": 87400, "sourceName": "statsV2.playCount", "unit": "count", "kind": "counter", "history": "live_snapshot", "precision": { "class": "platform_rounded", "step": 100, "rule": "tiktok.page.display.2026-11-02" } },
    "likes": { "value": 6383, "sourceName": "statsV2.diggCount", "unit": "count", "kind": "counter", "history": "live_snapshot", "precision": { "class": "as_served", "step": 1, "rule": "tiktok.page.display.2026-11-02" } },
    "comments": { "value": 33, "sourceName": "statsV2.commentCount" },
    "shares": { "value": 787, "sourceName": "statsV2.shareCount" },
    "saves": { "value": 381, "sourceName": "statsV2.collectCount" }
  },
  "data": { "text": "...", "hashtags": ["..."], "soundId": "...", "videoLocationCreated": "PT", "isAd": false, "authorPseudoId": "tiktok:a_...", "durationSec": 15, "pageStatusCode": 0, "pageServedRegion": "AE", "isPhotoPost": false, "privateItem": false, "secret": false }
}
```

A video that cannot be measured comes back as an `object_state` row with its own status and, when the page answered, the page's own code and message:

```json
{
  "recordType": "object_state",
  "platform": "tiktok",
  "sourceRecordId": "7042692929109986561",
  "status": "deleted",
  "statusReason": "TikTok page status 10204 (status_deleted)",
  "data": { "requestedAs": "7042692929109986561", "pageStatusCode": 10204, "pageStatusMsg": "status_deleted", "pageServedRegion": "AE" },
  "sampling": { "requestedAs": "7042692929109986561" }
}
```

| `status` | Meaning |
|---|---|
| `deleted` | TikTok's page names the video deleted |
| `not_public` | the video exists or existed, but the page does not show it to the public |
| `not_found` | TikTok says there is no such video. This does not prove it ever existed; an id you saw live earlier reads as removed since then |
| `region_blocked` | closed for the region of the collector address |
| `unavailable` | the page answered something we cannot read: an unknown code or message, or another video than the one requested (`data.errorCode` is `ID_MISMATCH`) |
| `blocked` | TikTok answered with a block or verification page: the video may well be alive. This also covers a 200 page without the data script or detail block that says captcha, verify you are human, access denied, unusual traffic or security check |
| `fetch_error` | our read failed: HTTP error, network, changed page layout. `data.errorCode` says which. This says nothing about the video. HTTP 503 without Retry-After counts here. With no response at all, `observedAtBasis` is `request_failed`: `observedAt` is when the read gave up |
| `not_attempted` | the run stopped before this video; `observedAt` is null |

#### Where the page codes get their meaning

TikTok documents none of these codes. `Seen` means we read that code and message on live pages on 2026-11-02. `status_deleted` and `status_self_see` were read from one address outside the Apify platform, so it is not established that a run on Apify gets the same messages. `Community` means a label from third-party open source projects: we never saw that code, and those projects partly disagree.

| Code and message | `status` | Where the meaning comes from |
|---|---|---|
| 10204 `status_deleted` | `deleted` | seen; the meaning rests on the message itself |
| 10204 `status_self_see` | `not_public` | seen; the message itself and one third-party patch |
| 10204 `item doesn't exist` | `not_found` | seen, also on ids that never existed |
| 10204, any other message | `unavailable` | counted in `counters.unclassified`; the source stays `partial` |
| 10216, 10217, 10221, 10222 | `not_public` | community, never seen by us |
| 10219, 10231 | `region_blocked` | community, never seen by us |
| 10000, 10113, 10114 | `blocked` | community, never seen by us |
| any other code | `unavailable` | counted in `counters.unclassified`; the source stays `partial` |

`deleted`, `not_public`, `not_found` and `region_blocked` are valid answers, so a run with only these and live videos is `ok`. A new code never reads as a valid answer until someone has looked at it.

The `MANIFEST` record in the key-value store reports the run status (`ok`, `partial`, `blocked`, `failed`), counts of measured, gone, region-blocked, blocked, failed, unclassified and not attempted videos, invalid inputs, cut videos and errors.

### How to read the data

- `observedAt` is read after the page response arrived, `collectedAt` when this actor built the row, so "what did we know at time T" uses `collectedAt`. `runStartedAt` is the platform start, the same on every row of a run. On a measured row `runStartedAt <= observedAt <= collectedAt`. The page states no date of its own for its counters, so `sourceUpdatedAt` is null and `sourceUpdatedAtBasis` says why.
- Counters are the rounded numbers TikTok's page shows at `observedAt`: below 10,000 as they are, from 10,000 in steps of 100, from 1,000,000 in steps of 100,000, from 1,000,000,000 in steps of 100,000,000. The step is what was measured, not the direction of the rounding. Stored data holds views and likes at the 100,000 step; shares at that step and views at the 100,000,000 step rest on one live sample each; comments have no sample from 1,000,000. Saves are not rounded. The rule was measured on 2026-11-02 and is not documented by TikTok, so it can change.
- Each metric carries `precision` with `class`, `step` and `rule`. `platform_rounded`: the true count is within about one step of the value. `as_served`: the page shows this number and no rounding is known. `rule_mismatch`: the value does not fit the rule, `step` is null, the value is untouched and the `MANIFEST` notes how many. `exact` is never written, and there are no low and high bounds, because the rounding direction is not established.
- `authorFollowers` follows the same rule, `authorFollowersStep` gives its step.
- Values come from `statsV2` first and from `stats` per field only if `statsV2` has no usable value: above 2^31 views `stats.playCount` wraps to a negative number. A positive value in `stats` beats a zero in `statsV2`. A counter in neither has `sourceName` `absent:statsV2.x|stats.x`.
- `pageServedRegion` is what TikTok reports for the address that read the page: evidence for region effects, not a market and not an audience. `requestRegion` is the country asked of the Apify proxy (`proxyConfiguration.apifyProxyCountry`), on post and state rows, with `requestRegionMethod`; without one it is null with `not_set`. It says what was asked, `pageServedRegion` what TikTok reports; neither is a market or an audience. `market` is null because counters are global, and `source.marketReason` says so.
- Author names are not stored as fields: `authorPseudoId` is a one-way hash. An original sound is titled by its author, so its title is not stored: `data.soundTitle` is null, `data.soundIsOriginal` is true and `data.soundId` stays. The handle appears in the video link, and the post text can hold whatever name its author wrote there. So the hash does not hide who posted.
- `data.hashtags` holds one tag per video, in the spelling that came first: the challenge list first, then the text. 12 of 92 stored rows held one tag in two cases; those stored rows are not rewritten.
- A value the page does not provide is null with a reason, never 0. Zero views on a live video are treated as not served. A video with 0 on every counter stays a post as served, views null and the others 0; nothing shows that block was served, so if views is unreadable or 0 on every post, or on 10% of 10 or more, the source is `partial` as described under a changed page.
- An empty counter block, or one whose only readable value is a zero of views, is `fetch_error` with `SCHEMA_CHANGED`, not a post full of nulls.
- **A changed page.** Every real page read so far sent playCount, diggCount, commentCount, shareCount, collectCount and createTime. If one of them has no readable value (absent, null, empty, not a number, or 0 for views) on every post of a run, from one post on, or on 10% or more of the posts when there are 10 or more, the source is `partial` with `SCHEMA_CHANGED` naming the field in `errors[].field`, with `affected` and `of`. `counters.unreadableFields` holds the count per field. The rows stay as they are: null with a reason, and `unparseable` with the raw value when the value is there but cannot be read. A real 0 of likes, comments, shares or saves is an answer and raises nothing.

### FAQ

**Are the numbers exact?** No. They are what TikTok's own page shows, and views, likes, comments and shares are rounded as described above. Two readers of this page agree with each other because both read the same rounded numbers, so agreement between them does not show exactness. This page has no exact counts to give.

**Is not_found reliable?** It is what the page answered. We have seen no live video answer `not_found`, but other readers report `10204` also for an address that TikTok treats differently, so this is not excluded. Pass `controlVideoUrl` with a video you know is live: when some video answers `not_found`, the control is read as well, without a row of its own. If it does not read as live, the source is `partial` with the error `NOT_FOUND_UNCONFIRMED`, and `sources[].applied.controlVideo` in the `MANIFEST` shows what it answered.

**What about photo posts?** `data.isPhotoPost` is set when the page has a photo block, but no photo post has been checked on a live page yet.

**What about search and hashtags?** Those lists are not served openly. Use modules `tiktok_search` and `tiktok_explore` of the *Unified Scraper Adapter*.

### Limits

- If TikTok starts blocking the page, the run stops and says so. No bypass is attempted. A real block, a private account and a region block have not been seen on this actor yet, so those paths rest on the community labels above.
- A migration or restart of the run is not a cut: the next worker goes on with the videos not yet read. A video whose row reached the dataset is counted from that row: it is not read again and gets no second row. A cut takes all the remaining videos at once, so it costs one push and a few writes however long the panel is. An aborted run names the videos left in `sources[].excluded` with reason `not_attempted:aborting`; the abort handler gives the collector up to 20 s to name them, and past the 200 listed in `sources[].excluded` the rest are in `sources[].applied.notAttemptedIds`. A full URL of a video that already came under a short link is not read again; `measured` is the number of videos with a post row.
- The counters are the value at the moment of measurement. They cannot be spread over past dates.
- Check that automated collection fits TikTok's terms and your use case.

### Cost

About $0.03-0.10 of platform usage per 1,000 videos, depending on your Apify plan, at 256 MB of memory and the default delay. 1,000 videos take about 40 minutes.

# Actor input Schema

## `videoUrls` (type: `array`):

TikTok video URLs, short links (vm.tiktok.com) or numeric video ids. Each video is measured separately; a deleted, closed or missing video is reported as an object_state row with its own status. For a panel measured over time use numeric ids: a dead short link has no id to join with earlier rows.

## `delayMs` (type: `integer`):

Pause between two page requests.

## `stopAfterBlocks` (type: `integer`):

Stop the run when this many videos in a row are blocked. Remaining videos are reported as not_attempted.

## `controlVideoUrl` (type: `string`):

URL or id of a video you know is live. When a video answers not_found, this one is read too, without a row of its own. If it does not read as live, the run reports NOT_FOUND_UNCONFIRMED and status partial: this address may get that answer for live videos too.

## `snapshotId` (type: `string`):

Your id for this snapshot, one per task of one actor. Rows get recordKey = platform:type:id:snapshotId, so a repeat with the same snapshotId gives the same keys, and a new id gives a new observation. A repeat reads the source again: compare provenance.contentChecksum to see whether the values moved. Default: <actor>.<runId>.

## `queryId` (type: `string`):

Your search profile id, copied into provenance.queryId.

## `queryVersion` (type: `string`):

Search profile version, copied into provenance.queryVersion.

## `panelId` (type: `string`):

Fixed panel id for refresh runs, copied into provenance.panelId.

## `panelVersion` (type: `string`):

Panel composition version; a new composition must get a new version.

## `maxItems` (type: `integer`):

Hard cap on dataset rows for this run. Separate from the run money cap (maxTotalChargeUsd), which is set on the run itself.

## `maxRunSeconds` (type: `integer`):

Stop starting new units this many seconds after the run started and finish with a partial manifest. The budget belongs to the run: a restart does not renew it, and the time between workers counts.

## `keepRaw` (type: `boolean`):

Store the raw upstream item in `raw`. Off by default. provenance.rawChecksum is present on rows built from a raw item; it is null on a state row with no answer behind it and on derived rows.

## `failOnPartial` (type: `boolean`):

By default a partial result finishes as SUCCEEDED with status=partial in the MANIFEST record. Failed and blocked always finish as FAILED.

## `proxyConfiguration` (type: `object`):

Proxy for upstream requests. Not needed in tests: video pages are served to datacenter addresses.

## Actor input object example

```json
{
  "videoUrls": [
    "https://www.tiktok.com/@tiktok/video/7106594312292453675"
  ],
  "delayMs": 1500,
  "stopAfterBlocks": 5,
  "keepRaw": false,
  "failOnPartial": false,
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

## `dataset` (type: `string`):

No description

## `manifest` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "videoUrls": [
        "https://www.tiktok.com/@tiktok/video/7106594312292453675"
    ],
    "proxyConfiguration": {
        "useApifyProxy": false
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("sergeyfaraday/tiktok-video-stats").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "videoUrls": ["https://www.tiktok.com/@tiktok/video/7106594312292453675"],
    "proxyConfiguration": { "useApifyProxy": False },
}

# Run the Actor and wait for it to finish
run = client.actor("sergeyfaraday/tiktok-video-stats").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "videoUrls": [
    "https://www.tiktok.com/@tiktok/video/7106594312292453675"
  ],
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}' |
apify call sergeyfaraday/tiktok-video-stats --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,sergeyfaraday/tiktok-video-stats"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/acrLVhZL4Wzp5E9vE/builds/ADb3DUTlpEe2qK0aZ/openapi.json
