Unified Scraper Adapter: Douyin, RedNote, TikTok, Trends, Ozon
Pricing
Pay per usage
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
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
0
Monthly active users
15 minutes ago
Last modified
Categories
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
module | What it collects | Underlying actor (owner/name) | Approx. Store price |
|---|---|---|---|
douyin_search | Douyin videos by keyword; hidden play counts stay null | memo23/douyin-scraper | $3.00 / 1,000 videos |
douyin_search_zen | the same through a reserve route; a free account gets 2 runs | zen-studio/douyin-search-scraper | $4.99 / 1,000 videos |
douyin_refresh | re-measure Douyin videos by id | memo23/douyin-scraper | $3.00 / 1,000 |
douyin_refresh_zen | the same through a reserve route | zen-studio/douyin-video-scraper | $6.99 / 1,000 |
xhs_search | Xiaohongshu (RedNote) notes by keyword | zen-studio/rednote-search-scraper | $5.99 / 1,000 notes |
xhs_refresh | re-measure notes by id | zen-studio/rednote-note-detail-scraper | $9.99 / 1,000 |
douyin_index | Douyin Index values for a period and related words. The daily series currently comes back empty: the source answers 400 | memo23/douyin-index-scraper | $0.03 / keyword series |
tiktok_search | TikTok videos by keyword or hashtag | clockworks/tiktok-scraper | $5.00 / 1,000 videos |
tiktok_search_apidojo | the same through a cheaper reserve route; at least 10 videos per query | apidojo/tiktok-scraper | $0.30 / 1,000 videos |
tiktok_refresh | re-measure TikTok videos by URL; the companion TikTok Video Stats actor does this for less | deepmine/tiktok-video-details | $0.35 / 1,000 |
tiktok_refresh_clockworks | the same through a reserve route | clockworks/tiktok-video-scraper | $3.00 / 1,000 |
tiktok_sound | videos using a sound, plus its usage count | clockworks/tiktok-sound-scraper | $5.00 / 1,000 videos |
tiktok_trends | TikTok Creative Center hashtag ranking; the source lagged 18 days when checked, so stale results are flagged | data_xplorer/tiktok-trends | $0.175 / top 100 |
tiktok_explore | live TikTok category feeds: a sample, not a ranking | clockworks/tiktok-explore-scraper | $1.30 / 1,000 videos |
douyin_hashtags | Douyin videos by hashtag and category feed, hashtag totals | memo23/douyin-scraper | $3.00 / 1,000 videos |
xhs_explore | Xiaohongshu general feed with full counters: a sample, not a ranking; no categories | dami_studio/rednote-scraper | $0.90 / 1,000 notes |
xhs_channels | Xiaohongshu category feeds: a sample, not a ranking; part of the notes come with feed data only | scrapesage/rednote-xiaohongshu-scraper | $4.00 / 1,000 notes |
google_trends | Google Trends series, several windows, comparisons, rescaling | automationnation/google-trends-scraper | $0.001 / keyword and window |
google_trends_scrapesage | the same through a reserve route | scrapesage/google-trends-scraper | $0.004 / request |
google_trends_apify | the same through a second reserve route | apify/google-trends-scraper | about $0.10-0.20 / request (event + compute + residential proxy) |
wb_products | Wildberries products, price, position, price history. The stock number WB exposes does not behave like stock: stockUnits stays null and the number is kept as stockReported | memo23/wildberries-scraper | $5.00 / 1,000 products |
ozon_products | Ozon products with separate regular and card prices | zen-studio/ozon-scraper-pro | $4.99 / 1,000 products |
ozon_reviews | Ozon reviews | zen-studio/ozon-product-reviews-scraper | $2.99 / 1,000 reviews |
ozon_rank | Ozon position in search results | themineworks/ozon-product-search | $2.50 / 1,000 rows |
chewy_products | Chewy products and all variants; reviews too, but chewy_reviews is cheaper | ahmed_jasarevic/chewy-scraper | $1.90 / 1,000 products, $0.40 / 1,000 reviews |
chewy_reviews | Chewy reviews of a product family, without buyer names | automation-lab/chewy-product-reviews-scraper | $0.07 / 1,000 reviews, $0.005 / run |
chewy_details | Chewy product pages: Autoship price, ingredients, guaranteed analysis, calories, life stage, breed size | arcana/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
- Pick a
moduleand fill in what it needs:queries,hashtags,idsorcategories. - Set
upstreamMaxTotalChargeUsd: this is the most the underlying actor may charge. - Read the rows from the dataset and the report from the
MANIFESTrecord.partialmeans the result is cut: the manifest says why.
Input
{"module": "tiktok_search","queries": ["matcha", "labubu"],"region": "US","maxResultsPerQuery": 20,"excludeTerms": ["giveaway"],"upstreamMaxTotalChargeUsd": 0.5}
queriesorqueryVariants({text, lang, kind}),hashtags,idsorstartUrls, 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 underanchorTermon Google Trends routes: there the list order decides which terms share a group, whiletaskChecksumstill 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.upstreamActorIdswitches to another actor,upstreamBuildpins a build. Field coverage shows at once whether a replacement fits.upstreamRunIdreads an existing upstream run without paying for a new one, one upstream run per run of this actor.upstreamDatasetIdandupstreamKeyValueStoreIdread 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.checkPermissionsOnlymakes 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.
maxRunSecondscounts 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 finishespartial; a new run under the samesnapshotIdwithin 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 isempty_validwithemptyProof: 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:
| Scenario | What it needs | Proved by run | What happens without it |
|---|---|---|---|
| Start an upstream actor and read its run and dataset | nothing extra | BmTWBl6VjSSdwMVTp, 312 rows | |
| Attach later to a run this actor started | nothing extra | gLWqceJyPzFFd9q2o, 312 rows | |
| Attach to a run started elsewhere | run and storages open by id (general access: anyone with ID can read) | jdNQFQFML0UtCMyKQ, 262 rows | run 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 closed | the dataset open by id too | see above | 3VUuE37oLQVq0HW68: 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 closed | the two fields declare READ on those storages; a run cannot be granted this way | measured 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
failedwithUPSTREAM_ATTACH_FORBIDDEN, which says what to do next, and theMANIFESTis written. - Rows can be read but the run cannot: rows are delivered, the source is
partialwithUPSTREAM_STATUS_UNKNOWN, because only the end of the run could confirm the output is complete.upstreamRun.statusisUNKNOWNandupstreamRun.statusReasonsays 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 withinmaxRunSeconds. applied.upstreamInputholds what the run was really started with, withupstreamInputSource: read_from_run. If that could not be read there is noupstreamInput, onlyupstreamInputAssumedwithupstreamInputSource: assumed_not_read.upstreamInputDifflists what differs from this request; a setting you asked for that differs givesREQUEST_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.attachholdsrunId,datasetId,keyValueStoreId,actorMatches,buildNumberandageHours. A run of another actor givesUPSTREAM_ATTACH_OTHER_ACTOR. A run older than 24 hours is a stale answer: rows carrydata.stale, the source carriesstale, and the result is partial withUPSTREAM_RUN_OLD, unlessstaleIsPartialis false, which keeps the note and the marks.observedAtalways 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 Trends: windows and rescaling
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.upstreamMaxTotalChargeUsdis 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,anchorornone. Derived rows arerescaled_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.
seriesCoveragein theMANIFESTseparates the grid from the data.earliestandlatestare the ends of the grid the source served;withData,observedZeros,unknownPoints,partialPoints,firstWithData,firstNonZero,lastWithDataandunknownInsideDataRangedescribe the data.missingInsidecounts 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.granularityand the metric window are ISO steps (P1W,P1D,PT1H).periodEndis inclusive and on the last row comes from the step.data.isPartialis what the source sent:true,false, or null withdata.isPartialReasonwhen the key is absent. The main route sends it on every point, the reserve routes only on an unfinished period.- On
google_trends_scrapesagethere is nohasDataflag, so a 0 stays 0 withdata.zeroConfirmed=falseand can stand for no data.seriesCoverage.unconfirmedZeroscounts 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_productsandozon_rankrefuse 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
samplingon search routes holdsquery,queryType(keyword,hashtag,sound,feed),positionandpositionSource.positionSourcenames the field the source numbers its results with (searchPosition), or saysorder returned by the upstream: the order of the underlying actor's dataset, counted per query, not a position measured on the platform.provenance.queryTextholds the same query.- An object seen again under another query, hashtag, feed or position stays one
postorproductrow. Each further hit is onerank_entryrow withdata.objectId,data.query,data.positionandmetrics.position.applied.additionalHitscounts them andsharedResultslists 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) anddata.requestedId. Rows with no request country havesource.marketnull andsource.marketReason. tiktok_soundhas no request-country option, soregionis not applied; its task and sound-level rows have moderefresh, its video rows modesearch.
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, anddata.authorFollowersStepgives the step of followers. The rule was measured on 2026-11-02 and is not documented by TikTok, so it can change. tiktok_search_apidojostaysas_served, because it is not established that it reads the same display values.xmolodtsov/tiktok-search-scraper, reached throughupstreamActorId, rounds saves too.- A value off the step keeps its number, gets
rule_mismatchand a note, the source getsPRECISION_RULE_MISMATCHand stays ok.tiktok_trendsandvideosWithSoundcarry 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_privateonly when the upstream says so; anything else isfetch_errorwithdata.errorCode, which says our read failed and nothing about the video, and the source ispartialwithUPSTREAM_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); onozon_ranka tile price under the card flag ismetrics.priceCard. - Rating and count metrics carry
scope(sku,group,family,unknown). Onwb_products,ratingandreviewCountare the SKU's and stay null without the SKU pair, and the group's values are only underratingGroupandreviewCountGroup. Their scope isunknownwhen the count is at least 97% of the group's on a card of several variants, because whose it is, is not established. data.sellerIdon Ozon is a number or a pseudonymozon:a_...: a seller's page address can hold a person's name.data.stockScopesays what a stock field covers,bound: lowermarks a capped value.pricePerKgcarriesbasis, or a null withno_weight,ambiguousorsold_by_volume.- Review routes write
applied.perProduct, one line per requested id. ozon_productsstarts one upstream run per query, because its rows name no query. A run with both queries and ids has the sourcesozon_products:requested idsandozon_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 statusfetch_error, and a later good read replaces them. empty_validmeans the upstream run succeeded and returned nothing for your filters.emptyProofsays how strong that is.- Requested ids that did not come back appear as
object_staterows withnot_returned, but only after a finished upstream run and a complete read. A repeated page, a short read, a cut or an unknown status givesREFRESH_CHECK_SKIPPEDinstead, because what was not read says nothing about an object. fieldCoverage,seriesCoverageand thedeliveredcount inperQuerycount rows that reached your dataset;returnedandupstreamPointscount what the upstream returned.applied.upstreamDatasetTotalis the size of the upstream dataset.- If
maxItemsis spent, no further upstream run is started: the remaining parts arefailedwithapplied.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.
fieldCoverageandupstreamFieldsSeenshow what your run actually received, so check them on your first run. failOnPartialmakes a partial result fail the run. By default the run succeeds and the manifest carriespartial.