I run a visa agency in Bali. The rest of my week goes into Apify Actors, and the TikTok Shop set is where I most often catch myself answering a question the data was never asked.

Here is the cleanest example I have. One run, 26 October 2026, two inputs side by side so the market, period and sort order are identical: the keyword skincare, and the category Skincare. Ten shops each, US, last 30 days, sorted by revenue from the shop's own accounts.

They have no shops in common. Not one.

One word, two different questions

The keyword list:

rank shop revenue own-account 1 Qure Skincare $1,330,218 $4,131 2 CLEARSTEM Skincare $273,903 $0 3 WNP SKINCARE $209,498 $9,870 4 AMINNAH SKINCARE $139,303 $62,163 5 Forever Bloom skincare $174,979 $24,066 … 10 Marin Skincare $47,655 $2,947 

The category list, same run:

rank shop revenue own-account 1 medicube US Store $19,405,609 $648,125 2 Dr.Melaxin $7,591,807 $431,526 3 BASED $968,826 $324,775 4 DRMTLGY Shop $773,575 $250,494 5 SKIN1004 US $1,155,304 $172,295 … 10 COSRX US $1,308,332 $105,933 

Two counts explain the whole thing: 10 of 10 keyword rows have "skincare" in the shop name. 0 of 10 category rows do. The keyword search on shops is a name match. It is not a "what does this shop sell" match, and nothing in the response says so.

So the biggest actual skincare sellers in the US — medicube at $19.4M for the month, Dr.Melaxin at $7.6M, COSRX, SKIN1004, Biodance — are all invisible to the word skincare, because none of them put it in their store name. The top of the keyword list is 14.6× smaller than the top of the category list. If you took the keyword answer as "the skincare market on TikTok Shop", you would have sized a $19M category off a $1.3M shop.

This is not a bug in my Actor or upstream — it is a keyword index doing what keyword indexes do. It only becomes wrong when the word you search for is also a product category, which on a shopping platform is most of the interesting words. air fryer, lip gloss, protein: each is both a thing people sell and a thing people name their store after.

The rank field lies in one of the two lists

Both lists come back with rank: 1 through rank: 10. Only one of them is ranked.

I asked for sortBy: "ownAccountRevenue". Here is that column in the order it arrived.

Category list:

648125.14, 431526.2, 324775, 250493.61, 172295, 151624, 141356, 123470.2, 106053.85, 105933.12 

Monotonically descending — exactly what I asked for. And you can see it is the requested sort and not a revenue sort, because the revenue column in that same list is scattered: $19.4M, $7.6M, $969K, $774K, $1.16M, $1.27M.

Keyword list:

4131, 0, 9870, 62162.78, 24066, 17576.4, 4976.18, 398, 130, 2946.62 

Not sorted by own-account revenue. Not sorted by revenue either — rank 5 ($174,979) outsells rank 4 ($139,303), and rank 9 ($98,946) outsells rank 6 ($86,715). For keyword inputs the source returns relevance order and silently ignores the sort you chose, while my Actor keeps numbering the rows 1, 2, 3 because that is their position in the response.

A numeric field called rank that means "position in the response" in one branch and "position in a ranking" in the other is a trap I built myself. The honest reading: on any row where source == "keyword", treat rank as a row number. If you need a sorted list, use a category.

The one thing in these rows I trust completely is the revenue split: affiliateRevenue + ownAccountRevenue + shopWindowRevenue summed to revenue to the cent on 20 of 20 rows. It is also the pair worth the run — medicube takes 3.3% of its revenue through its own accounts, Dr.Melaxin 5.7%, Consult Beaute 79.3%. Sorting by own-account revenue is how you find the shops that have not figured out affiliates yet, which is the whole prospecting case for an agency.

Sorting live streams by views gets you the wrong lives

Same Actor, other list. US, last 7 days, sortBy: "views", analytics on.

rank creator views revenue duration 1 @victoriassecret 1,814,218 $21,835.10 47 min 2 @platinumcommerce 1,469,353 $10,195.99 4,074 min 3 @based 1,433,277 $0 3,864 min 4 @refreshed.tech 593,248 $0 811 min 5 @plugbettertech 498,106 $0 699 min 6 @dollskill 478,258 $0 841 min 7 @platinumcommerce 465,634 $0 1,449 min 8 @victoriassecret 464,702 $0 120 min 9 @luxury.good2145… 458,530 $0 5,425 min 10 @dollskill 451,922 $0 841 min 

Eight of ten rows have revenue 0. The highest-reach lives in a market are mostly lives that did not sell, or did not report sales — so "top lives" is two different lists depending on one input. Sort by revenue for lives that moved product, by views only if the question is genuinely about audience.

Two other things in that table I checked before believing:

views and viewers are the same number — until they are not. Nine of ten rows have them identical. The exception is rank 1: views 1,814,218, viewers 4,761,326. Viewers exceeding views is nonsense if you assume one is a subset of the other, so they are not the same metric; they come from different places upstream and only coincide most of the time. Do not build a ratio out of them.

That matters because of gpm (revenue per 1,000 views). Row 1 reports gpm: 4.59. Compute it yourself:

21835.10 / 1814218 * 1000 = 12.04 ← from views 21835.10 / 4761326 * 1000 = 4.59 ← from viewers 

GPM is calculated against viewers. On nine rows out of ten you cannot tell, because the two columns agree. On the one row where they diverge, the published number is 2.6× lower than the one a reader would derive from the columns next to it.

A "live stream" can run for days. durationMinutes matches endedAt - startedAt to the minute on all ten rows, and rank 9 ran 5,425 minutes — 90 hours. These are 24/7 auction channels, not broadcasts; filter on duration before you average anything.

And period: "last7Day" is the source's window, not one ending now. This run was scraped on 26 October; the ten lives started between 11 and 22 October and ended by 23 October. Nothing from the last three days. If you need yesterday, this is not the feed.

Input, output, API

{ "listType": "shops", "categories": ["Skincare"], "country": "US", "period": "last30Day", "sortBy": "ownAccountRevenue", "maxResultsPerSource": 10 } 

A real row from the run above:

{ "type": "shop", "rank": 7, "shopId": "7496086693137582689", "shopName": "Consult Beaute", "shopType": "BRAND", "revenue": 178291, "unitsSold": 3625, "avgSellingPrice": 49.18, "revenueGrowthPercent": -26.16, "affiliateRevenue": 18306, "ownAccountRevenue": 141356, "shopWindowRevenue": 18629, "productsOnSale": 50, "country": "US", "period": "last30Day", "currency": "USD", "source": "category", "sourceInput": "Skincare", "scrapedAt": "2026-10-26T13:09:17.514Z" } 

(The real row also carries a category field: for a category input it is the whole 15-node subtree of Skincare, pipe-joined and identical on every row — the input, not the shop's own category. I left it out of the snippet.)

Note source and sourceInput. With several inputs in one run, those two fields are the only way to know which question produced a row — and after the above, the difference between keyword and category is not cosmetic.

from apify_client import ApifyClient client = ApifyClient("<YOUR_API_TOKEN>") run = client.actor("lergassy/tiktok-shop-shops-live-scraper").call(run_input={ "listType": "shops", "categories": ["Skincare"], "country": "US", "sortBy": "ownAccountRevenue", "maxResultsPerSource": 10, }) for row in client.dataset(run["defaultDatasetId"]).iterate_items(): if row["type"] == "notice": continue print(row["rank"], row["shopName"], row["ownAccountRevenue"], row["source"]) 

That type == "notice" skip is not boilerplate. Unless you switch it off, the last row of a run is a free summary row, and it is where I put the billing so it cannot be argued with:

Run summary: 20 row(s) delivered, 0 free; charged shop ×20 = $0.072. 

The lives run, with analytics on, printed charged live-analytics ×10 = $0.38. Analytics rows cost roughly ten times a plain row, because each one is a separate licensed data call behind the scenes — turn the option off unless you need viewers, gpm and the product count. Both runs are from TikTok Shop Shops & Live Scraper.

When this is the wrong tool

It reports on shops and live streams, so it answers "who sells", not "what sells". Product-level questions — a listing's price history, its commission, which videos push it — belong to a different list, and I keep those in a separate Actor. The hard limit: it reads a licensed analytics source, not TikTok's pages. Anything that source does not model — comments, live chat, the video itself — is not in here at any price, and the freshness is the source's, measured above as a window ending a few days back.

The portable part is smaller than the Actor. Any ranked list that accepts both a free-text query and a taxonomy node is really two products wearing one response shape. The free-text branch will match names; the taxonomy branch will match meaning; both will come back with rank: 1 at the top. Run them side by side once, on a word that is also a category, and count the overlap. Mine was zero out of ten.