# Unified Scraper Adapter: Douyin, RedNote, TikTok, Trends, Ozon (`sergeyfaraday/unified-scraper-adapter`) Actor

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.

- **URL**: https://apify.com/sergeyfaraday/unified-scraper-adapter.md
- **Developed by:** [Sergey Faraday](https://apify.com/sergeyfaraday) (community)
- **Categories:** Social media, E-commerce
- **Stats:** 2 total users, 0 monthly users, 84.8% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

Pay per usage

This Actor is paid per platform usage. The Actor is free to use, and you only pay for the Apify platform usage, which gets cheaper the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-usage

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

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

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

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

```json
{
  "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](#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:

| 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 `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:

```json
{
  "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. `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`.

```json
{
  "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`.

# Actor input Schema

## `module` (type: `string`):

What to collect. Search, refresh-by-ID and discovery are separate modules; a missing mode is never replaced by another.

## `queries` (type: `array`):

Keywords (search modules, index series). RU, EN and ZH variants are separate queries; the collector returns raw matches.

## `queryVariants` (type: `array`):

Search profile as \[{"text": "...", "lang": "ru|en|zh", "kind": "original|translit|synonym"}]. Texts are added to queries; lang/kind are copied into sampling.queryVariant of matching rows.

## `excludeTerms` (type: `array`):

Whole-word (substring for Chinese) exclusions applied to title/text/name/hashtags of returned posts and products. Excluded objects are listed in the MANIFEST with the term, never silently lost.

## `ids` (type: `array`):

Stable ids or URLs for refresh modules (aweme ids, XHS note ids, TikTok URLs, Ozon SKUs, Chewy product URLs, WB nmIds). Ids that do not come back are reported as object_state rows.

## `hashtags` (type: `array`):

tiktok_search only: hashtag feeds.

## `startUrls` (type: `array`):

wb_products only: category or search URLs.

## `maxResultsPerQuery` (type: `integer`):

Per-keyword limit sent upstream. Yield below it is reported as upstream/platform limit, never as market coverage.

## `sort` (type: `string`):

Upstream sort value (douyin: general|most_liked|latest; xhs: general|popularity_descending|time_descending; tiktok: MOST_RELEVANT|MOST_LIKED|LATEST; wb: popular|newly|rate...; ozon: score|new|rating...). Echoed in every row.

## `timeWindow` (type: `string`):

douyin: unlimited|one_day|one_week|half_year; xhs: all|1d|1w|6mo; tiktok: ALL_TIME|PAST_24_HOURS|PAST_WEEK|PAST_MONTH|LAST_3_MONTHS|LAST_6_MONTHS.

## `noteType` (type: `string`):

xhs_search: all|video|image|live.

## `topUpFromOtherSorts` (type: `boolean`):

Off by default: merging sort orders and time ranges changes what the search sample means. When on, it is recorded in sampling.

## `categories` (type: `array`):

tiktok_explore: food, animals, daily_life, beauty_care, ... ; douyin_hashtags: food, knowledge, game, cartoon, music; xhs_channels: recommend, food, fashion, cosmetics, travel, fitness, household, career, love, movie_tv, gaming, video.

## `region` (type: `string`):

Request region for TikTok (proxy country / Creative Center country). Request region is not audience country.

## `period` (type: `string`):

7, 30 or 120.

## `industryId` (type: `string`):

Creative Center industry id; empty = all.

## `geo` (type: `string`):

ISO country code, default US. Use RU for the transfer check.

## `timeRange` (type: `string`):

e.g. "today 5-y" (weekly, about 4+ years), "today 3-m" (daily tail).

## `customTimeRange` (type: `string`):

"YYYY-MM-DD YYYY-MM-DD"; takes precedence over time range.

## `timeRanges` (type: `array`):

Several windows for one task, e.g. \["today 5-y", "today 3-m", "now 7-d"]: about 4 years weekly plus a daily and an hourly tail. Each window keeps its own native points; with rescale they are also chained onto the longest window.

## `anchorTerm` (type: `string`):

Comparison mode: queries are sent in groups of up to 4 together with this anchor term (one Google request per group), so terms from different groups can be put on one scale through the anchor. No commas inside terms. 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.

## `rescale` (type: `string`):

auto: overlap when a term has 2+ windows, anchor when anchorTerm is set. Adds rescaled_series_point rows (history=derived, algorithm id + version + fit quality); native points are never changed.

## `category` (type: `string`):

Category id, empty = all.

## `searchType` (type: `string`):

Web search uses search terms; other types are sent as explore URLs with gprop. Topic ids (/m/..., /g/...) can be passed as queries.

## `timeRangeDays` (type: `integer`):

Daily points requested (upstream claim 7-365; real depth reported per keyword).

## `indexPlatform` (type: `string`):

douyin or toutiao. Different indices are never merged.

## `includeRelated` (type: `boolean`):

Douyin Index: related-word rows (last full week).

## `maxRelated` (type: `integer`):

Douyin Index: related-word rows per keyword (default 20).

## `deliveryCity` (type: `string`):

Fixed delivery region for WB prices and stock (default moscow).

## `deliveryCities` (type: `array`):

Ozon: fixed delivery city (first one is recorded as region).

## `includeReviews` (type: `boolean`):

Reviews are off by default (cost).

## `maxReviewsPerProduct` (type: `integer`):

Initial load suggestion: 100 per SKU; incremental runs use reviewsSince.

## `reviewsSince` (type: `string`):

Drop reviews published before this moment (incremental collection). ISO 8601 with a zone (2026-10-01T00:00:00Z) 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.

## `controlQuery` (type: `string`):

A query you know has results, sent in the same upstream run. It proves the source was working, so an empty answer for your other queries counts as a proven empty result. Refused on wb_products, ozon_products and ozon_rank: they start one upstream run per query.

## `language` (type: `string`):

Language of the source where the underlying actor lets you choose, for example ru. Recorded in the manifest and on rows where the source states no language of its own.

## `market` (type: `string`):

Market label, for example RU. Recorded in the manifest and on rows where the source states no market of its own. To change where data comes from, use region, geo or city where the module has them.

## `windowTimezone` (type: `string`):

Timezone of the date window you requested, for example Asia/Shanghai. Recorded in the manifest; the underlying actors apply their own timezone.

## `purgeUpstreamDataset` (type: `boolean`):

Delete the raw dataset of the underlying run once it is read completely, if your data retention rules require it.

## `upstreamMaxTotalChargeUsd` (type: `number`):

maxTotalChargeUsd passed to the upstream run. A per-run cap does not limit the sum of many runs: keep your daily or monthly budget separately.

## `upstreamBuild` (type: `string`):

Pin a build number (e.g. 0.0.125) for reproducible runs. Default: the module's pinned value ("latest" until acceptance fixes a number).

## `upstreamRunId` (type: `string`):

Read an existing upstream run instead of starting one (after an adapter timeout the MANIFEST names it). No new paid start, and one upstream run per adapter run. A run started outside this actor is readable only when it is open by id; otherwise pass the two storage fields below. See the Permissions section of the README.

## `upstreamDatasetId` (type: `string`):

Dataset of the upstream run. Declares READ on that dataset for this run, so rows can be delivered even when the run itself cannot be read; the status of the run then stays UNKNOWN and the result is partial. Measured under a limited run token on 2026-11-03 on one account: the grant reads the dataset and the store, the run stays 403. Run with checkPermissionsOnly first. Opening the run and its storages by id is the other measured route.

## `upstreamKeyValueStoreId` (type: `string`):

Default key-value store of the upstream run. Declares READ on it for this run, so the settings the run really used are read from its INPUT record instead of being assumed. Measured under a limited run token on 2026-11-03 on one account. Run with checkPermissionsOnly first.

## `staleIsPartial` (type: `boolean`):

An attached 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. Switch off to keep the marks and a note and let the source stay ok. observedAt always stays the time the upstream measured.

## `checkPermissionsOnly` (type: `boolean`):

Make each read of the scenario once (attach: run, its INPUT, dataset, key-value store; start: upstream actor lookup), start nothing and report every outcome in applied.permissions. applied.expected, expectedStatus and expectedCodes say what the same input will do without this flag, and an input it would refuse is reported here. The source is ok when the scenario can work, failed otherwise. Run it before an expensive collection.

## `upstreamActorId` (type: `string`):

Use another actor with a compatible output (fallback). Mapping coverage in the manifest shows whether it fits.

## `upstreamMemoryMbytes` (type: `integer`):

Override upstream memory.

## `upstreamTimeoutSecs` (type: `integer`):

Override upstream timeout.

## `upstreamMaxItems` (type: `integer`):

Pay-per-result cap on billed dataset items (does not guarantee the number of rows).

## `upstreamInputOverride` (type: `object`):

Raw fields merged into the upstream input last. Recorded in the manifest.

## `echoUpstreamInput` (type: `boolean`):

Store the exact upstream input (applied filters) in the manifest.

## `snapshotId` (type: `string`):

Your id for this snapshot, one per task of one actor. Rows get recordKey = platform:type:id:snapshotId, so a repeat with the same snapshotId gives the same keys, and a new id gives a new observation. A repeat reads the source again: compare provenance.contentChecksum to see whether the values moved. Default: <actor>.<runId>.

## `queryId` (type: `string`):

Your search profile id, copied into provenance.queryId.

## `queryVersion` (type: `string`):

Search profile version, copied into provenance.queryVersion.

## `panelId` (type: `string`):

Fixed panel id for refresh runs, copied into provenance.panelId.

## `panelVersion` (type: `string`):

Panel composition version; a new composition must get a new version.

## `maxItems` (type: `integer`):

Hard cap on dataset rows for this run. Separate from the run money cap (maxTotalChargeUsd), which is set on the run itself.

## `maxRunSeconds` (type: `integer`):

Stop starting new units this many seconds after the run started and finish with a partial manifest. The budget belongs to the run: a restart does not renew it, and the time between workers counts.

## `keepRaw` (type: `boolean`):

Store the raw upstream item in `raw`. Off by default. provenance.rawChecksum is present on rows built from a raw item; it is null on a state row with no answer behind it and on derived rows.

## `failOnPartial` (type: `boolean`):

By default a partial result finishes as SUCCEEDED with status=partial in the MANIFEST record. Failed and blocked always finish as FAILED.

## `proxyConfiguration` (type: `object`):

Proxy for upstream requests. Not used by the adapter itself (upstream actors manage their own proxies).

## Actor input object example

```json
{
  "module": "tiktok_refresh",
  "queries": [
    "冻干狗粮"
  ],
  "ids": [
    "https://www.tiktok.com/@tiktok/video/7106594312292453675"
  ],
  "topUpFromOtherSorts": false,
  "rescale": "auto",
  "searchType": "web",
  "includeRelated": false,
  "includeReviews": false,
  "purgeUpstreamDataset": false,
  "upstreamMaxTotalChargeUsd": 1,
  "staleIsPartial": true,
  "checkPermissionsOnly": false,
  "echoUpstreamInput": true,
  "keepRaw": false,
  "failOnPartial": false,
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

## `dataset` (type: `string`):

No description

## `manifest` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "module": "tiktok_refresh",
    "queries": [
        "冻干狗粮"
    ],
    "ids": [
        "https://www.tiktok.com/@tiktok/video/7106594312292453675"
    ],
    "proxyConfiguration": {
        "useApifyProxy": false
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("sergeyfaraday/unified-scraper-adapter").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "module": "tiktok_refresh",
    "queries": ["冻干狗粮"],
    "ids": ["https://www.tiktok.com/@tiktok/video/7106594312292453675"],
    "proxyConfiguration": { "useApifyProxy": False },
}

# Run the Actor and wait for it to finish
run = client.actor("sergeyfaraday/unified-scraper-adapter").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "module": "tiktok_refresh",
  "queries": [
    "冻干狗粮"
  ],
  "ids": [
    "https://www.tiktok.com/@tiktok/video/7106594312292453675"
  ],
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}' |
apify call sergeyfaraday/unified-scraper-adapter --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,sergeyfaraday/unified-scraper-adapter"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/VLptwNdf3xIcN70v2/builds/oDdEW2suHHTUALgir/openapi.json
