技術ログ

Exact TikTok follower counts are in statsV2; stats is rounded

公開: 2026-09-14 · 著者: GRAMSHIFT

The field tutorials commonly read stayed at 18,000,000 while the account gained 46,663

Exact TikTok follower counts are in statsV2; stats is rounded

Start with the most extreme case. On 2026-09-06 the JSON embedded in the Duolingo profile page on TikTok said "followerCount": 18000000. On 2026-09-14 the same field in the same account's page still said 18000000. A daily log built on that field would have recorded eight days without a single new follower.

In the same JSON object, directly after it, a second block called statsV2 said 17,960,673 on the first date and 18,007,336 on the second. That is 46,663 new followers that never reached the field tutorials commonly read.

If you came here to record exact TikTok follower counts every day, this is the whole answer in four lines:

  1. Fetch the public profile page, https://www.tiktok.com/@username, and parse the <script id="__UNIVERSAL_DATA_FOR_REHYDRATION__"> block.
  2. Read __DEFAULT_SCOPE__["webapp.user-detail"].userInfo.statsV2.followerCount. It is a string holding the exact figure. userInfo.stats.followerCount is a rounded number, the "18.0M" kind.
  3. Before you store anything, rule out the two responses that also arrive as HTTP 200: a page of about 1.5 KB with no data in it (a bot check), and a full page whose statusCode is 10221 (no profile at that name).
  4. Keep the last exact counts per user ID, compare them on each daily run, and add a row to your history only when a count moved.

The rest of this page is the evidence for each line, measured against four public accounts on 2026-09-14 between 07:40 and 07:47 UTC, one request per account, and compared with responses saved from the same accounts on 2026-09-06.

Two follower counts sit side by side in the profile JSON

This is the relevant part of the page for @nasa, copied from the response received at 07:42 UTC on 2026-09-14. The page named itself "uniqueId": "nasa", "nickname": "NASA", verified:

"stats":   {"followerCount":1700000,  "followingCount":23,  "heart":8700000,  "heartCount":8700000,  "videoCount":44,  "diggCount":0,  "friendCount":17}
"statsV2": {"followerCount":"1690808","followingCount":"23","heart":"8737542","heartCount":"8737542","videoCount":"44","diggCount":"0","friendCount":"17"}

(Spacing added for alignment. In the page the two objects are adjacent, stats first.)

Three things are visible in those two lines. The follower count and the like count differ between the objects; the small counts (following, videos, friends) do not. The values in stats are JSON numbers and the values in statsV2 are strings. And stats comes first, which matters because the obvious path, userInfo.stats.followerCount, is the one people write.

They do write it. Of four tutorials from the first page of results for my searches on this topic, opened on 2026-09-14, two read the follower count as userInfo["stats"]["followerCount"] in their example code, and none of the four mentions statsV2 at all. Their code is not wrong about where the number lives. It is reading a number that was rounded before it was written into the page.

stats is the abbreviated count, written out in full

Here are the four accounts measured on 2026-09-14. The "account" column is the uniqueId the page itself returned, not the name I asked for; both matched in all four cases.

Account (as returned)stats.followerCountstatsV2.followerCountstatsV2 minus stats
nasa (NASA)1,700,0001,690,808−9,192
duolingo (Duolingo)18,000,00018,007,336+7,336
shopify (Shopify)2,100,0002,089,390−10,610
mkbhd (Marques Brownlee)2,300,0002,341,756+41,756

For accounts above one million followers, stats.followerCount is statsV2.followerCount rounded to the nearest 100,000: a "1.7M" written back out as 1,700,000. That held for all four accounts on 2026-09-14 and for all nine accounts above one million in the set saved on 2026-09-06, thirteen readings in total. TikTok does not document this, so treat it as an observation rather than a rule, but the consequence does not depend on the exact rounding step: the field can be wrong by up to half a step in either direction. For MKBHD the field understated the account by 41,756; for Shopify it overstated it by 10,610.

Likes behave the same way. Duolingo's stats.heartCount was 499,000,000 against 499,018,894 in statsV2.

Eight days in which three of four rounded counts did not move

The single-day gap is not the real problem. A history is. On 2026-09-06, while the tool described at the end of this page was being built, the full responses for @nasa and @duolingo were saved, and both fields were recorded for @shopify and @mkbhd, so the same two fields can be compared across eight days. For @nasa and @duolingo the numeric user ID in both responses is identical (7664638705177150477 and 6917704832925746181), so this is the same account, not the same name on a different account.

Accountstats, 09-06stats, 09-14statsV2, 09-06statsV2, 09-14Real change
duolingo18,000,00018,000,00017,960,67318,007,336+46,663
shopify2,100,0002,100,0002,058,4812,089,390+30,909
mkbhd2,300,0002,300,0002,335,0562,341,756+6,700
nasa1,600,0001,700,0001,568,2561,690,808+122,552

Three of the four rounded values are identical across the eight days, while the exact counts grew by 6,700 to 46,663. The fourth is worse in a different way: NASA's rounded value jumped by exactly 100,000 because the account crossed the 1,650,000 boundary, while the real change was 122,552. A log built on stats therefore records one of two things for a large account: zero, or a jump of a whole rounding step. Neither is the change that happened. Growth rates, "followers gained this week", and alerts on a sudden drop all come out wrong, and a quiet week for a competitor looks identical to a busy one.

Reading statsV2 without inventing a number

The extraction is short. The care goes into what it refuses to return. This is Node 18 or later, with no dependencies, and I ran it against the six responses saved for this article before publishing it:

function readProfile(status, html) {
  if (status === 403 || status === 429 || /SlardarWAF|slardar_\w*_waf/.test(html)) {
    return { kind: 'blocked' };
  }
  const m = html.match(/<script[^>]*id="__UNIVERSAL_DATA_FOR_REHYDRATION__"[^>]*>([\s\S]*?)<\/script>/);
  if (status !== 200 || !m) return { kind: 'unreadable', status };
  const detail = JSON.parse(m[1]).__DEFAULT_SCOPE__?.['webapp.user-detail'];
  if (detail?.statusCode === 10221) return { kind: 'no-profile' };
  if (detail?.statusCode !== 0 || !detail.userInfo?.user?.uniqueId) {
    return { kind: 'unreadable', code: detail?.statusCode ?? null };
  }
  const exact = detail.userInfo.statsV2 ?? {};
  const shown = detail.userInfo.stats ?? {};
  const toInt = (v) => (/^\d+$/.test(String(v ?? '')) ? Number(v) : null);
  return {
    kind: 'ok',
    userId: detail.userInfo.user.id,
    username: detail.userInfo.user.uniqueId,
    followers: toInt(exact.followerCount),
    likes: toInt(exact.heartCount),
    videos: toInt(exact.videoCount),
    followersShown: toInt(shown.followerCount),
  };
}

Run against the saved @duolingo response it returns followers: 18007336 and followersShown: 18000000; against the home-connection response described below it returns blocked; against the made-up name it returns no-profile.

Three details are deliberate. toInt returns null for anything that is not a string of digits, never 0, because Number(undefined) is NaN and Number(null) is 0, and a zero written into a follower history is a fake collapse that someone will chart. The function returns userId as well as the username, because a username can change and the ID is what you should key your history on. And it keeps the rounded figure next to the exact one, so the gap stays visible instead of being silently thrown away.

You only need the profile page.

A 1,462-byte HTTP 200 is a bot check, not an account with no followers

At 07:40:52 UTC on 2026-09-14 I requested https://www.tiktok.com/@nasa from a desktop machine on an ordinary home connection in Japan, with a normal browser User-Agent. The response was HTTP 200 and 1,462 bytes long. This is most of it:

<script id="slardar-config" type="application/json">
  {
    "slardarClient": "SlardarWAF",
    ...
    "bid": "slardar_us_waf",
    ...
  }
</script>
...
<body>
  Please wait...
  <p id="wci" class="_wafchallengeid"></p>

There is no __UNIVERSAL_DATA_FOR_REHYDRATION__ in it and no profile. About a minute later the same URL, requested through an Apify datacenter proxy, returned HTTP 200 with the full page, about 369 KB, and the counts in the tables above. The same was true for the other three accounts and the made-up name, all through the datacenter proxy.

The trap is that the status code is identical. A scraper that trusts 200 and then fails to find the JSON has to decide what to write, and "0 followers" or an empty row is what naive code writes. Detect the challenge by what is in it (SlardarWAF or slardar_..._waf), store nothing for that account on that run, and try again later or from a different network.

Do not detect it by searching for the word captcha. Every normal profile page saved for this article loads a script whose file name contains it (captcha-ttp.bebf6f1e.js on 2026-09-14), so that test flags every real page as blocked.

statusCode 10221: the page loads, the profile is not there

A username that does not exist does not produce a 404. https://www.tiktok.com/@zzz9f2aq, requested at 07:46 UTC through the datacenter proxy, returned HTTP 200 and about 366 KB, with the same <title>TikTok - Make Your Day</title> as the NASA page. The difference is one field:

"webapp.user-detail":{"statusCode":10221,"statusMsg":""

Two rules follow. First, branch on statusCode, not on the HTTP status or the title. Second, do not branch on statusMsg. On 2026-09-06 a different made-up name, one containing hyphens, returned the same 10221 with "statusMsg":"user banned", while zzz9f2aq returned an empty message on both dates. Neither account was banned; neither ever existed. If your monitor turns that message into "account banned" in a report, it is inventing a fact.

For a daily history, the useful response to 10221 on a name you have been tracking is to stop writing rows for it and flag it for a person. I have only observed this code for names that never existed, so I cannot tell you from measurement whether a renamed or removed account returns the same code. Treat it as "no profile at this name today" and nothing more.

A daily history that only grows when a count moves

With a reliable reader, the monitor itself is a comparison against yesterday. Keep one small record per user ID: the last exact counts and when they were read. On each run:

function compare(today, previous) {
  if (!previous) return { changed: true, firstCheck: true, delta: null };
  const known = (a, b) => a !== null && b !== null;
  const moved = ['followers', 'likes', 'videos']
    .filter((k) => known(today[k], previous[k]) && today[k] !== previous[k]);
  const delta = known(today.followers, previous.followers)
    ? today.followers - previous.followers : null;
  return { changed: moved.length > 0, firstCheck: false, moved, delta };
}

Given today's Duolingo reading and a stored followers: 17960673 from 2026-09-06, it returns moved: ["followers"] and delta: 46663. Given yesterday's exact numbers unchanged, it returns changed: false, and you write nothing.

The rules around it are where monitors usually break:

  • Only an ok reading updates the record. A blocked, no-profile or unreadable run leaves yesterday's numbers in place, so tomorrow's delta covers two days instead of starting from a false zero.
  • If either side is unknown, the delta is unknown. null minus 17,960,673 is not −17,960,673, and 18,007,336 minus null is not a gain of 18 million. Write null.
  • Key the record by user ID, not by your list. If the key includes the list of accounts, adding one name to the list resets the history for every other account, and the next run reports all of them as new.
  • Check the returned uniqueId against the name you asked for. If they differ, record both. Otherwise one account's numbers can quietly be appended to another's history.
  • Once a day is enough for growth tracking, and one page request per account, several seconds apart, keeps the load small.

On a machine you control, a cron entry or a scheduled task that runs the reader, the comparison, and an append to a CSV or database table is the entire system. Most of the maintenance is the first section of this page: noticing when the page format changes, and when the network you run from starts receiving the 1,462-byte page instead of the profile.

Scheduling the same checks without maintaining the parser

If you would rather not keep that layer running, the checks above are what our Apify Actor does (checked against its source code on 2026-09-14): TikTok Follower Count Scraper & Monitor requests only /@username pages and returns followerCount from statsV2, with the rounded figure in followerCountDisplay and the gap in followerCountDisplayDiff. A bot-check page is returned as a blocked row and retried through a proxy session when proxies are enabled; code 10221 comes back as no-such-profile without any claim that the account was banned; an unreadable count is null, not zero; and every profile row carries usernameMatchesInput. With monitoring mode on, it remembers the counts per account, returns a profile only when one of its counts moved, and adds previousFollowerCount, followerDelta and changedFields. You can run it daily with an Apify schedule.

At the time of writing it is priced per profile returned ($0.008 each on Apify's free plan, less on paid plans), plus a $5 monitoring fee charged once per calendar month on the first monitoring run. Rows for blocked pages, missing profiles and unchanged profiles are not charged.

Published by GRAMSHIFT. The 2026-09-14 figures come from six requests to www.tiktok.com between 07:40 and 07:47 UTC: one from a home connection in Japan (the 1,462-byte response), and five through an Apify datacenter proxy (the first of those runs reported a US address; the country of the others was not recorded), sent one at a time at least 20 seconds apart, one request per account. Each figure rests on that single request. The 2026-09-06 figures come from full responses saved on that date for @nasa and @duolingo, and from recorded field values for @shopify and @mkbhd, all through the same kind of proxy, and were not re-measured. The rounding to the nearest 100,000 is inferred from thirteen readings of accounts above one million followers and is not documented by TikTok; accounts below one million were not analysed for this article, and the pages were not rendered in a browser, so what the profile screen displays was not compared. The four tutorials were opened on 2026-09-14 and are not named because their authors may since have changed them. This article is not affiliated with TikTok, NASA, Duolingo, Shopify or Marques Brownlee, and it does not assess whether collecting this data fits TikTok's terms for your use. On the use of AI: the measurement scripts, the sample code, this article and the Actor were written by Claude, an AI model, working for GRAMSHIFT; every figure was compared against the saved responses, and both code samples were run against them.

よくある質問