I run a visa agency in Bali. Scrapers on Apify are the other half of my week, and one of them does a narrow job: every buyer review of a TikTok Shop product in the US or the UK — stars, text, date, photos, the variant bought, the reviewer.

The reason people buy review data is text: the complaints, the feature requests, the sentence that explains why 4.5 stars is not 4.9. So before writing another word about "AI review analysis", I counted how much text is actually in there. On the product my own health check hits every night, the answer is a quarter of it.

Two samples, the same 72%

The test product is a US supplement listing, 1729448464509734958 — Toplux Magnesium Complex. On 8 October TikTok reported 170,737 reviews on it; today it reports 180,293, so about 330 arrive a day.

Sample one: a single run on 8 October, reviewsNewerThan: "3 days", cap 5,000. It returned 1,071 reviews in 46 seconds, and reviewText was an empty string on 797 of them — 74.4%.

Sample two, in case that was one strange weekend: twelve nightly 100-row runs between 13 October and today, deduplicated by reviewId into 964 unique reviews. Empty text on 686 — 71.2%.

Together, 2,035 unique reviews, 1,483 of them with nothing written in them: 72.9%. The 274 texts in sample one add up to 3,087 words. That is a long blog post, extracted from 1,071 paid rows.

Nothing is broken: TikTok Shop asks for a star rating in one tap after delivery and treats the text box as optional. But if your pipeline is rows → LLM → themes, you are paying for four rows to get one sentence, and you should know that going in.

The complaints talk, the praise does not

Here is the part that changes an analysis. Share of reviews carrying any text, by star rating:

stars sample 1 (1,071) sample 2 (964)
5★ 197 of 848 — 23% 186 of 739 — 25%
4★ 20 of 79 — 25% 16 of 79 — 20%
3★ 11 of 45 — 24% 14 of 38 — 37%
2★ 4 of 20 — 20% 6 of 17 — 35%
1★ 42 of 79 — 53% 56 of 91 — 62%

An angry buyer is two to three times more likely to type something than a happy one. The middle of the scale is noisy because the counts are small, but both ends replicate across two samples a month apart.

The consequence is worth a sticky note. In sample one the mean rating over all 1,071 rows is 4.491; over the 274 rows that have text it is 4.190. Drop the silent rows — exactly what a text-mining pipeline does — and the product loses 0.3 stars before any model sees it.

So compute the distribution from every row, and read the text knowing it over-represents the 1-star tail by design. If you want complaints and nothing else, ask for them instead of filtering a full export: reviewsFilter: "1" walks the 1-star filter directly, and on a UK lash-extension product it returned all 26 one-star reviews in a single API call, 17 with text (65% — same pattern, other market).

What the text is when there is text

Of the 274 texts in sample one:

  • 65 are three words or fewer. Real examples: Great product, Excelente 👌, received, 9.5/1, Muy bueno.
  • 9 contain no Latin letters at all — 👍👍👍👍👍, ♥️♥️, 10/10, and one Arabic line that translates to "please stop sending me the magnesium".
  • 45 are 20 words or more — the useful tail, which includes an 83-word four-star review admitting the buyer has not tried the product and is rating the other reviews.
  • 76 are in Spanish. There is no language field on the row — TikTok does not send one — so detect it yourself before you average sentiment across a mixed bag.
  • 5 used TikTok's prompted template (Effectiveness: Good, Ease of Use: Easy, Packaging: Very good). I expected these structured lines to dominate; they are 2% of the texts. Don't build a parser for them.

And photos are rarer than the Store screenshots suggest: 55 of 1,071 rows have at least one image (5%), and 12 of those 55 have a photo and no text, which is the only case where reviewImages is the whole review.

Three fields that carried no information, and two that do

Measured over all 2,035 unique rows:

  • verifiedPurchase was true on 2,032. Three false. It is not a filter, it is a constant.
  • incentivized was false on every single row. Don't build a "paid review share" chart on it without checking your own data first.
  • variant was "Default" on all 2,035 — one SKU. On the UK lash product the same field is real (60&80D 10-20mm-3D Effect Lash) and gives you complaints per variant: the field is only as informative as the listing.

The two that do work, with a catch each. reviewerId is a stable numeric ID — 987 distinct reviewers in sample one, 39 of them reviewing twice — but it is null on 47 rows (4.4%), so your group-by needs a fallback bucket. And reviewerName is masked by TikTok before it reaches me: J**, A**a, 💋**🧸. Sample one has 900 distinct masked names for 987 reviewers, and ** appears 8 times. Never deduplicate reviewers by name.

Timestamps, at least, are honest: reviewedAt is a real millisecond-precision UTC instant on all 1,071 rows, not a "4 months ago" string.

The ceiling nobody mentions: 5,000 per filter

This is the limit to plan around. One sort order plus one filter stops at about 5,000 reviews: page 51 of 100 answers has_more: false. Asking deeper gets you nothing.

To go past it, the Actor reads the star breakdown out of the first answer and walks the combinations — each star filter, the other sort order for buckets over 5,000, the photos-only and verified-only filters — dropping duplicate reviewIds. That is how "25,000 reviews per product" is reached, and 25,000 is the hard cap per run.

On a product with 180,293 reviews, that is 14% at the absolute best, and it is not a random 14%. The honest ways to use this:

  • Newest-first with a date window. reviewsNewerThan: "1 day" on this product returned 351 reviews in 27 seconds — a complete, unbiased slice of one day. Schedule it daily and you build the full history going forward instead of fighting for the past.
  • One star rating at a time, when the bucket is small enough to finish (all 26 one-star UK reviews, one call).
  • Treat a single giant history dump as a sample, and say so in the report you build on it.

A smaller listing has no such problem: under 5,000 the first walk gets everything, and the run statistics report TikTok's own total per product so you can check.

How it gets them, in case you are building your own

POST /api/shop/pdp_desktop/get_product_reviews only answers a request signed by TikTok's own page scripts. Plain HTTP returns {"code":10000} on every route I tried — direct, residential proxy, a commercial unblocker, the mobile twin of the page — and headless Chromium is refused as well (code 100000). What works is a headful browser on a virtual display: open one product page of the market, wait for the security scripts, then ask the API for any product ID in that market. page_size: 100 is accepted — 1,071 reviews took 13 calls and one browser launch.

Two quirks. An empty page that arrives without has_more: false is a hiccup, not the end of the list — ask again and it comes back. And geography is per market: US reviews answer from Apify's own datacenter address, a UK product needs a UK residential one, and a UK product asked from a US page returns zero reviews.

Calling it

{ "productUrls": ["https://shop.tiktok.com/us/pdp/1729448464509734958"], "maxReviewsPerProduct": 2000, "reviewsNewerThan": "1 day", "reviewsSort": "newest", "reviewsFilter": "all" } 

A real row from today's run, trimmed:

{ "type": "review", "reviewId": "7705163491157870350", "productId": "1729448464509734958", "productTitle": "Toplux Magnesium Complex 8 Essential Magnesium Supplement", "country": "US", "reviewRating": 2, "reviewText": "Lo acato de enpesar a usted y muy bueno", "reviewedAt": "2026-11-06T20:49:58.096Z", "reviewerName": "💋**🧸", "reviewerId": "6979971148214518789", "verifiedPurchase": true, "incentivized": false, "variant": "Default", "skuId": "1729448464509800494", "reviewImageCount": 1, "reviewCountry": "US", "scrapedAt": "2026-11-06T20:52:22.707Z" } 

Two stars, text in Spanish that reads like praise, one photo. That row contains most of this article.

From Python, run it and measure what I have been measuring:

import requests, collections rows = requests.post( "https://api.apify.com/v2/acts/lergassy~tiktok-shop-reviews-scraper/run-sync-get-dataset-items", params={"token": "apify_api_..."}, json={"productUrls": ["https://shop.tiktok.com/us/pdp/1729448464509734958"], "maxReviewsPerProduct": 300, "reviewsNewerThan": "2 days"}, timeout=900, ).json() r = [x for x in rows if x.get("type") == "review"] # skip free notice rows empty = sum(1 for x in r if not (x.get("reviewText") or "").strip()) print(len(r), empty, collections.Counter(x["reviewRating"] for x in r)) 

The Actor is TikTok Shop Reviews Scraper: $0.0015 per review (down to $0.00105 on higher Apify plans) plus $0.005 per run start, no login, no cookies. Today's control run with "Max cost per run" set to $0.05 delivered 33 reviews in 21.6 seconds and charged $0.04455, and 11 of those 33 rows had text — the same share I started with. Notice and error rows are free.

When it is the wrong tool. Only US and UK products serve the review list on the web — a link from any other market gets a free notice row explaining that. If you need price, stock, sales or shop stats too, reviews alone will not give them to you; that is a job for TikTok Shop Scraper. There are no seller replies and no Q&A, because the public page has none. And if your deliverable is prose for a model to read, budget by words, not by rows: on this listing, 1,000 paid reviews buy about 2,900 words over 256 texts, 60 of them three words or fewer.