Unified Scraper Adapter: Douyin, RedNote, TikTok, Trends, Ozon avatar

Unified Scraper Adapter: Douyin, RedNote, TikTok, Trends, Ozon

Pricing

Pay per usage

Go to Apify Store
Unified Scraper Adapter: Douyin, RedNote, TikTok, Trends, Ozon

Unified Scraper Adapter: Douyin, RedNote, TikTok, Trends, Ozon

Runs a proven Store scraper under a money cap and returns one consistent format for Douyin, RedNote, TikTok, Google Trends, Wildberries, Ozon and Chewy, with field coverage, null vs zero and cost reports.

Pricing

Pay per usage

Rating

0.0

(0)

Developer

Sergey Faraday

Sergey Faraday

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

0

Monthly active users

15 minutes ago

Last modified

Share

Runs one proven Apify Store scraper under a hard money cap and converts its output into one consistent format, so you can swap sources without rewriting your pipeline. Every run comes with a report of what the source actually delivered.

What you get on top of the underlying scraper

  • One format for every source: the same row structure with time fields, metrics and provenance.
  • Honest numbers: a counter the platform hides comes back as null with a reason, not as 0. Each metric names the upstream field it was read from.
  • Money safety: the upstream build is pinned, each upstream run has its own charge cap, and a start with an unknown outcome is never repeated, so you do not pay twice.
  • Run report: field coverage, per-query yield, ids that did not come back, schema change detection and upstream cost.
  • No secrets in rows: signed URL tokens are removed.

Modules

moduleWhat it collectsUnderlying actor (owner/name)Approx. Store price
douyin_searchDouyin videos by keyword; hidden play counts stay nullmemo23/douyin-scraper$3.00 / 1,000 videos
douyin_search_zenthe same through a reserve route; a free account gets 2 runszen-studio/douyin-search-scraper$4.99 / 1,000 videos
douyin_refreshre-measure Douyin videos by idmemo23/douyin-scraper$3.00 / 1,000
douyin_refresh_zenthe same through a reserve routezen-studio/douyin-video-scraper$6.99 / 1,000
xhs_searchXiaohongshu (RedNote) notes by keywordzen-studio/rednote-search-scraper$5.99 / 1,000 notes
xhs_refreshre-measure notes by idzen-studio/rednote-note-detail-scraper$9.99 / 1,000
douyin_indexDouyin Index values for a period and related words. The daily series currently comes back empty: the source answers 400memo23/douyin-index-scraper$0.03 / keyword series
tiktok_searchTikTok videos by keyword or hashtagclockworks/tiktok-scraper$5.00 / 1,000 videos
tiktok_search_apidojothe same through a cheaper reserve route; at least 10 videos per queryapidojo/tiktok-scraper$0.30 / 1,000 videos
tiktok_refreshre-measure TikTok videos by URL; the companion TikTok Video Stats actor does this for lessdeepmine/tiktok-video-details$0.35 / 1,000
tiktok_refresh_clockworksthe same through a reserve routeclockworks/tiktok-video-scraper$3.00 / 1,000
tiktok_soundvideos using a sound, plus its usage countclockworks/tiktok-sound-scraper$5.00 / 1,000 videos
tiktok_trendsTikTok Creative Center hashtag ranking; the source lagged 18 days when checked, so stale results are flaggeddata_xplorer/tiktok-trends$0.175 / top 100
tiktok_explorelive TikTok category feeds: a sample, not a rankingclockworks/tiktok-explore-scraper$1.30 / 1,000 videos
douyin_hashtagsDouyin videos by hashtag and category feed, hashtag totalsmemo23/douyin-scraper$3.00 / 1,000 videos
xhs_exploreXiaohongshu general feed with full counters: a sample, not a ranking; no categoriesdami_studio/rednote-scraper$0.90 / 1,000 notes
xhs_channelsXiaohongshu category feeds: a sample, not a ranking; part of the notes come with feed data onlyscrapesage/rednote-xiaohongshu-scraper$4.00 / 1,000 notes
google_trendsGoogle Trends series, several windows, comparisons, rescalingautomationnation/google-trends-scraper$0.001 / keyword and window
google_trends_scrapesagethe same through a reserve routescrapesage/google-trends-scraper$0.004 / request
google_trends_apifythe same through a second reserve routeapify/google-trends-scraperabout $0.10-0.20 / request (event + compute + residential proxy)
wb_productsWildberries products, price, position, price history. The stock number WB exposes does not behave like stock: stockUnits stays null and the number is kept as stockReportedmemo23/wildberries-scraper$5.00 / 1,000 products
ozon_productsOzon products with separate regular and card priceszen-studio/ozon-scraper-pro$4.99 / 1,000 products
ozon_reviewsOzon reviewszen-studio/ozon-product-reviews-scraper$2.99 / 1,000 reviews
ozon_rankOzon position in search resultsthemineworks/ozon-product-search$2.50 / 1,000 rows
chewy_productsChewy products and all variants; reviews too, but chewy_reviews is cheaperahmed_jasarevic/chewy-scraper$1.90 / 1,000 products, $0.40 / 1,000 reviews
chewy_reviewsChewy reviews of a product family, without buyer namesautomation-lab/chewy-product-reviews-scraper$0.07 / 1,000 reviews, $0.005 / run
chewy_detailsChewy product pages: Autoship price, ingredients, guaranteed analysis, calories, life stage, breed sizearcana/chewy-com-scraper$5.00 / 1,000 products

Prices were noted on 2026-11-02 and belong to the owners of those actors, so they can change. Some actors add a small start fee.

Use a paid Apify plan. Several underlying actors limit free-plan accounts to a preview (for example one query, 3 results or 100 items). The adapter reports this as partial with UPSTREAM_PLAN_LIMIT, never as a complete result. Some actors also require a minimum charge cap, for example $0.50 for clockworks/tiktok-scraper. A price the source does not provide, such as the Ozon subscription price, is null with a reason.

You pay for the underlying actor at its own Store price, plus a small amount of compute for this actor. upstreamMaxTotalChargeUsd (default $1) caps the charge of each upstream run. The cap applies per run, so budget many runs separately.

How to use

  1. Pick a module and fill in what it needs: queries, hashtags, ids or categories.
  2. Set upstreamMaxTotalChargeUsd: this is the most the underlying actor may charge.
  3. Read the rows from the dataset and the report from the MANIFEST record. partial means the result is cut: the manifest says why.

Input

{
"module": "tiktok_search",
"queries": ["matcha", "labubu"],
"region": "US",
"maxResultsPerQuery": 20,
"excludeTerms": ["giveaway"],
"upstreamMaxTotalChargeUsd": 0.5
}
  • queries or queryVariants ({text, lang, kind}), hashtags, ids or startUrls, depending on the module. Lists are sent in sorted order, so the same panel in another order is the same task and gives the same rows. The exception is queries under anchorTerm on Google Trends routes: there the list order decides which terms share a group, while taskChecksum still ignores order.
  • excludeTerms: matching objects are dropped and listed in the manifest with the reason.
  • snapshotId: your id for this measurement. Re-running with the same id does not create duplicates; a new id creates a new snapshot.
  • upstreamActorId switches to another actor, upstreamBuild pins a build. Field coverage shows at once whether a replacement fits.
  • upstreamRunId reads an existing upstream run without paying for a new one, one upstream run per run of this actor. upstreamDatasetId and upstreamKeyValueStoreId read its storages directly when the run itself cannot be read; that grant was measured under a limited token on one account on 2026-11-03. See Permissions.
  • checkPermissionsOnly makes each read of the scenario once, starts nothing and reports every outcome.
  • A window, a category, a country code or a list of ids that cannot work is refused before any paid start, and no upstream run is started with less than 15 seconds of the time budget left or on a stop signal.
  • maxRunSeconds 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.
  • reviewsSince: ISO 8601 with a zone, or a bare date read as UTC midnight. A review exactly at the boundary is kept. Any other format fails the run at the start. If every row the upstream returned is older, the result is empty_valid with emptyProof: upstream_rows_older_than_since.

Permissions

This actor runs with limited permissions: its run token reads what it creates itself and the storages you grant in the input, nothing else. You never put an API token in the input. Whether a run started elsewhere can be read therefore depends on how that run is shared. Measured on 2026-11-02 on a test account, with upstream run 3GQ0u8UmYJu5RTAwp started outside this actor:

ScenarioWhat it needsProved by runWhat happens without it
Start an upstream actor and read its run and datasetnothing extraBmTWBl6VjSSdwMVTp, 312 rows
Attach later to a run this actor startednothing extragLWqceJyPzFFd9q2o, 312 rows
Attach to a run started elsewhererun and storages open by id (general access: anyone with ID can read)jdNQFQFML0UtCMyKQ, 262 rowsrun closed, WCw5L5XAkSCxETDnv: Insufficient permissions for the Actor run. Make sure you're passing a correct API token and that it has the required permissions.
The same, run open but its dataset closedthe dataset open by id toosee above3VUuE37oLQVq0HW68: Insufficient permissions for the dataset. Make sure you're passing a correct API token and that it has the required permissions.
Attach with upstreamDatasetId and upstreamKeyValueStoreId, run closedthe two fields declare READ on those storages; a run cannot be granted this waymeasured on 2026-11-03 on one account: dataset and store read, the run stays 403, the result is partial with UPSTREAM_STATUS_UNKNOWN

A refused read is never retried and no longer stops the run; the two failures above ended as a crash on the build they were measured on, and now end as follows.

  • No rows can be read: the source is failed with UPSTREAM_ATTACH_FORBIDDEN, which says what to do next, and the MANIFEST is written.
  • Rows can be read but the run cannot: rows are delivered, the source is partial with UPSTREAM_STATUS_UNKNOWN, because only the end of the run could confirm the output is complete. upstreamRun.status is UNKNOWN and upstreamRun.statusReason says why. It is never assumed. Reads of the status that fail for another reason get a quarter of the time left, from 5 to 60 seconds, so the rows are still read within maxRunSeconds.
  • applied.upstreamInput holds what the run was really started with, with upstreamInputSource: read_from_run. If that could not be read there is no upstreamInput, only upstreamInputAssumed with upstreamInputSource: assumed_not_read. upstreamInputDiff lists what differs from this request; a setting you asked for that differs gives REQUEST_NOT_APPLIED, and no derived rows are built on it.
  • Several windows or comparison groups need several upstream runs, so attach refuses them with UPSTREAM_ATTACH_MULTI_PART.
  • applied.attach holds runId, datasetId, keyValueStoreId, actorMatches, buildNumber and ageHours. A run of another actor gives UPSTREAM_ATTACH_OTHER_ACTOR. A run older than 24 hours is a stale answer: rows carry data.stale, the source carries stale, and the result is partial with UPSTREAM_RUN_OLD, unless staleIsPartial is false, which keeps the note and the marks. observedAt always stays the time the upstream measured.

Check before an expensive collection. This reads, starts nothing and costs only a moment of compute:

{
"module": "google_trends",
"upstreamRunId": "3GQ0u8UmYJu5RTAwp",
"upstreamDatasetId": "gr81G4xD4Kx3wgVxS",
"upstreamKeyValueStoreId": "EV44OOdXwcTSctYa4",
"checkPermissionsOnly": true
}

The source <module>:permission_check is ok when the scenario can work and failed otherwise, and applied.permissions lists each read as { operation, target, ok, httpStatus, error }: run, run_input, dataset, key_value_store, or for a start actor and run_list. It also says what the same input will do without the flag: applied.expected, expectedStatus, expectedCodes. An input the real run would refuse is reported as canWork: false with its code. A check does not affect a collection under the same snapshotId: its manifest has checkOnly: true.

Not measured under the limited token yet: the dataset metadata read, the run lookup after a lost start reply, the log read used for plan limits, purgeUpstreamDataset, alias datasets and the actor lookup before a start. Every run records their outcome in applied.permissions, and a refusal is a named note (UPSTREAM_RUN_LOOKUP_FORBIDDEN, UPSTREAM_INPUT_FORBIDDEN, UPSTREAM_LOG_FORBIDDEN, UPSTREAM_PURGE_FORBIDDEN, UPSTREAM_ACTOR_LOOKUP_FORBIDDEN), never a crash.

Google scales every request to its own maximum, so numbers from two requests are not comparable. This module keeps the native points untouched and adds separate derived rows.

  • timeRanges: ["today 5-y", "today 3-m", "now 7-d"]: three windows, one upstream run each. upstreamMaxTotalChargeUsd is shared between them.
  • anchorTerm: terms are sent in groups of up to 4 together with the anchor, one group per Google request. Groups are cut in list order, up to 4 terms each. Keep terms of similar volume next to each other, because Google scales a group to its highest point and a very large term rounds small ones to 0. Add new terms at the end: full groups keep their series keys. Inside a group the order does not matter.
  • If Google was asked something other than you requested (window, geo, search type), the part is marked REQUEST_NOT_APPLIED.
  • rescale: auto (default), overlap, anchor or none. Derived rows are rescaled_series_point, with the factor, reference window, fit quality and algorithm version on each row. A window that fails the quality checks gets no derived rows, and the manifest says why.
  • A low or zero value for a rare term is not proof of no interest.
  • seriesCoverage in the MANIFEST separates the grid from the data. earliest and latest are the ends of the grid the source served; withData, observedZeros, unknownPoints, partialPoints, firstWithData, firstNonZero, lastWithData and unknownInsideDataRange describe the data. missingInside counts positions between the first and the last point with data that are absent or came back with no data. A five-year window does not mean five years of history: one measured series had 262 positions from 2021-10-31, with its first non-zero value on 2024-08-04.
  • All of these describe one pull. Two pulls of the same request on the same day gave 118 and 122 unknown points, so they can change between pulls.
  • time.granularity and the metric window are ISO steps (P1W, P1D, PT1H). periodEnd is inclusive and on the last row comes from the step.
  • data.isPartial is what the source sent: true, false, or null with data.isPartialReason when the key is absent. The main route sends it on every point, the reserve routes only on an unfinished period.
  • On google_trends_scrapesage there is no hasData flag, so a 0 stays 0 with data.zeroConfirmed=false and can stand for no data. seriesCoverage.unconfirmedZeros counts these.
  • Related topics are not delivered on any route, see applied.relatedTopics; related queries are.
  • The same stored rows and algorithm version give the same derived rows. A new pull gives other numbers, so compare factors within factorRelSe.
  • controlQuery: a query you know has results, sent in the same upstream run. It proves an empty answer for your other queries is real. wb_products, ozon_products and ozon_rank refuse it: they start one upstream run per query, so the control query would not be in the same run.

Output

Rows share one structure: recordType (post, product, review, series_point, related_term, rank_entry, topic_state, rating_summary, object_state, rescaled_series_point), recordKey, snapshotId, platform, sourceRecordId, canonicalUrl, source, time, metrics, data, sampling, provenance.

{
"recordType": "post",
"platform": "tiktok",
"sourceRecordId": "...",
"time": { "publishedAt": "2025-08-02T18:45:03.000Z", "sourceUpdatedAt": null, "sourceUpdatedAtBasis": "not_provided_by_source", "observedAt": "2026-11-02T05:08:27.454Z", "observedAtBasis": "upstream_run_finished", "availableAt": null, "availableAtReason": "not_provided_by_source", "collectedAt": "2026-11-02T05:09:02.118Z", "runStartedAt": "2026-11-02T05:06:51.302Z" },
"metrics": { "likes": { "value": 5344, "sourceName": "diggCount", "unit": "count", "kind": "counter", "window": "lifetime_to_observation", "history": "live_snapshot", "nullReason": null } },
"provenance": { "upstreamActor": { "actorId": "clockworks/tiktok-scraper", "buildNumber": "0.0.637", "runId": "..." } }
}

observedAt is the time of the item if the upstream gives one, else a bound from its run or dataset, and observedAtBasis says which: upstream_item_timestamp, upstream_run_finished, upstream_dataset_modified, upstream_run_started. On an upstream error row of a read that got no answer, the item's own time is the moment it gave up: request_failed. Unknown stays null with not_measured, as on derived rows. collectedAt is when this actor received the row, so "what did we know at time T" uses collectedAt. runStartedAt is the platform start of this actor's run. sourceUpdatedAt is what the source says about the version it served, otherwise null with the reason in sourceUpdatedAtBasis.

Search routes

  • sampling on search routes holds query, queryType (keyword, hashtag, sound, feed), position and positionSource. positionSource names the field the source numbers its results with (searchPosition), or says order returned by the upstream: the order of the underlying actor's dataset, counted per query, not a position measured on the platform. provenance.queryText holds the same query.
  • An object seen again under another query, hashtag, feed or position stays one post or product row. Each further hit is one rank_entry row with data.objectId, data.query, data.position and metrics.position. applied.additionalHits counts them and sharedResults lists the ids. A further hit is not a platform ranking.
  • By-id rows carry sampling.requestedAs (the upstream's echo, else the input entry holding the id, signed parameters and a TikTok author handle removed) and data.requestedId. Rows with no request country have source.market null and source.marketReason.
  • tiktok_sound has no request-country option, so region is not applied; its task and sound-level rows have mode refresh, its video rows mode search.

TikTok counters

  • Counters carry precision {class, step, rule}: the rounded numbers TikTok's page shows: 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, and data.authorFollowersStep gives the step of followers. The rule was measured on 2026-11-02 and is not documented by TikTok, so it can change.
  • tiktok_search_apidojo stays as_served, because it is not established that it reads the same display values. xmolodtsov/tiktok-search-scraper, reached through upstreamActorId, rounds saves too.
  • A value off the step keeps its number, gets rule_mismatch and a note, the source gets PRECISION_RULE_MISMATCH and stays ok. tiktok_trends and videosWithSound carry no precision: the rule was not measured on them.
  • Views of 0 on TikTok routes are null with zero_not_confirmed_as_real.
  • An upstream error row for a requested video is deleted_or_private only when the upstream says so; anything else is fetch_error with data.errorCode, which says our read failed and nothing about the video, and the source is partial with UPSTREAM_ROW_ERROR.

Personal data

An original sound is titled by its author, so data.soundTitle is null, data.soundIsOriginal true and data.soundId stays. Suppliers with no flag are read by the title or by a sound credited to the video's channel. Author names are not stored as fields: data.authorPseudoId is a one-way hash. The handle remains in TikTok's video link, so the hash does not hide who posted, and the post text holds whatever its author wrote.

Products and reviews

  • Every price metric carries priceCondition (regular_no_card, card, advertised, autoship, unknown); on ozon_rank a tile price under the card flag is metrics.priceCard.
  • Rating and count metrics carry scope (sku, group, family, unknown). On wb_products, rating and reviewCount are the SKU's and stay null without the SKU pair, and the group's values are only under ratingGroup and reviewCountGroup. Their scope is unknown when the count is at least 97% of the group's on a card of several variants, because whose it is, is not established.
  • data.sellerId on Ozon is a number or a pseudonym ozon:a_...: a seller's page address can hold a person's name.
  • data.stockScope says what a stock field covers, bound: lower marks a capped value.
  • pricePerKg carries basis, or a null with no_weight, ambiguous or sold_by_volume.
  • Review routes write applied.perProduct, one line per requested id.
  • ozon_products starts one upstream run per query, because its rows name no query. A run with both queries and ids has the sources ozon_products:requested ids and ozon_products:<query>.

The MANIFEST record in the key-value store contains the upstream run, dataset, build and charge, fieldCoverage, perQuery, seriesCoverage, excluded objects and error codes (REFRESH_MISSING_IDS, REFRESH_CHECK_SKIPPED, SERIES_MISSING_PERIODS, SCHEMA_CHANGED, MAPPING_MISS, REPEATED_PAGE, SHORT_READ, BUDGET, RELATED_TOPICS_DELIVERED, PRECISION_RULE_MISMATCH, REVIEW_ORDER_UNKNOWN, REVIEW_LINK_UNKNOWN, UPSTREAM_*).

  • Items that came back but could not be mapped give failed, never an empty success.
  • Changed source shapes: each route has fields that define a row. If they are absent or unreadable on every row, from a single row, or on 10% or more of 10 or more rows, the source is partial with SCHEMA_CHANGED, or failed when no row holds data. Such rows have status fetch_error, and a later good read replaces them.
  • empty_valid means the upstream run succeeded and returned nothing for your filters. emptyProof says how strong that is.
  • Requested ids that did not come back appear as object_state rows with not_returned, but only after a finished upstream run and a complete read. A repeated page, a short read, a cut or an unknown status gives REFRESH_CHECK_SKIPPED instead, because what was not read says nothing about an object.
  • fieldCoverage, seriesCoverage and the delivered count in perQuery count rows that reached your dataset; returned and upstreamPoints count what the upstream returned. applied.upstreamDatasetTotal is the size of the upstream dataset.
  • If maxItems is spent, no further upstream run is started: the remaining parts are failed with applied.partStatus: not_attempted.

FAQ

Why did I get only a few results? On a free Apify plan several underlying actors return a preview. Look for UPSTREAM_PLAN_LIMIT in the manifest and use a paid plan.

Why was the start rejected? Some actors require a higher charge cap, for example at least $0.50. Raise upstreamMaxTotalChargeUsd.

Is an empty result real? Send a controlQuery on a route that accepts it. Without it the manifest says emptyProof: upstream_success_only.

Why UPSTREAM_ATTACH_FORBIDDEN? The run you attached to was started outside this actor and is not shared. Open the run and its storages by id, or pass upstreamDatasetId and upstreamKeyValueStoreId. If you already passed upstreamDatasetId, only the dataset's owner can help by opening it by id. A failed read or a wrong id is UPSTREAM_READ_FAILED or UPSTREAM_NO_DATASET, not a refusal. Run with checkPermissionsOnly first to see each read.

Limits

  • Results depend on the underlying actors: their availability, limits and output can change. fieldCoverage and upstreamFieldsSeen show what your run actually received, so check them on your first run.
  • failOnPartial makes a partial result fail the run. By default the run succeeds and the manifest carries partial.