# TikTok Ad Hashtag Miner — hashtags that top ads actually use (`titan_coder/tiktok-ad-hashtag-miner`) Actor

TikTok's own Trending Hashtags page shows three rows and then asks you to log in. This Actor mines hashtags straight out of the ad copy on the public Top Ads board and ranks them by how many high-performing ads use them, with CTR and likes attached.

- **URL**: https://apify.com/titan\_coder/tiktok-ad-hashtag-miner.md
- **Developed by:** [Radu Furtuna](https://apify.com/titan_coder) (community)
- **Categories:** Social media, Marketing
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

Pay per event

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

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

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
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.
Actors are written with capital "A".

## 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.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## TikTok Ad Hashtag Miner — the hashtags that top-performing ads actually use

TikTok's own **Trending Hashtags** page moved into TikTok One and now shows **three rows** before it
asks you to log in. This Actor gets at hashtag data from the other side: it reads the **public Top Ads
board** in TikTok Creative Center, pulls the hashtags out of the ad copy, and ranks them by how many
high-performing ads use them — with CTR, likes, industry and country attached to every hashtag.

**Be clear about what this is.** These are hashtags used in **paid ads that performed well**, not
organic TikTok trends. That is the point: an organic trend tells you what people are watching, this
tells you what advertisers are actually spending money on and how those creatives performed. Both are
useful; they are not the same thing, and this Actor does not pretend otherwise.

### What you get

One row per hashtag:

| Field | Meaning |
|---|---|
| `hashtag` | the hashtag as it is written in the ads (first spelling seen) |
| `hashtagKey` | lower-cased key used for grouping |
| `adCount` | how many **distinct** ads used it |
| `avgCtr`, `medianCtr`, `maxCtr` | click-through rate across those ads |
| `ctrAdCount`, `likesAdCount`, `durationAdCount` | how many ads actually carried each metric — the denominator, which can be lower than `adCount` |
| `avgLikes`, `totalLikes` | engagement across those ads |
| `avgDurationSec` | average video length of the ads using it |
| `topIndustries`, `topObjectives` | which industries and campaign objectives it shows up in (raw codes) |
| `topIndustryNames`, `topObjectiveNames` | the same, as human-readable names |
| `countries` | which country boards it was found on |
| `sampleAds` | up to 3 example ads: id, copy, CTR, likes, `adUrl` (its card in TikTok Creative Center) and `videoId` |
| `isPartial` | `true` if the run's coverage was incomplete (see below) |
| `isTruncatedSample` | `true` if any slice hit the page cap while the source still reported more results |
| `taxonomySnapshotDate` | which taxonomy snapshot resolved the industry/objective names |
| `runId`, `scrapedAt` | run identity and UTC timestamp |

### How it works

1. **No third-party account needed.** Creative Center renders client-side and refuses requests made
   outside a real browser session, so a real anti-detect browser is required — this Actor uses a
   built-in stealth browser by default. You can point it at your own remote browser (`cdpUrl`,
   e.g. a Bright Data Scraping Browser) instead; the backend is never switched automatically and is
   reported in the run's coverage. Either way this Actor only reads what the public board loads for
   an ordinary visitor.
2. You pick **countries** and **periods**. Every `country × period` pair is one slice of the board.
3. Each slice is swept to its full depth — up to **100 ads** (5 pages × 20), the source's own hard
   ceiling per slice.
4. Ads are **de-duplicated by ad id across slices** before they are counted, so an ad that appears on
   both the US and GB boards adds `1` to a hashtag's `adCount`, not `2`. (The overlap is real:
   measured US/30d ∩ GB/30d = 38 of ~100.)
5. Hashtags are aggregated across the whole run and returned ranked by `adCount`.

### Input

```json
{
  "countryCodes": ["US", "GB", "DE"],
  "periods": [7, 30],
  "orderBy": "ctr",
  "maxPagesPerSlice": 5,
  "minAdsPerHashtag": 2,
  "maxHashtags": 500
}
```

- `orderBy` — `ctr` and `impression` are deterministic and repeatable between runs; `for_you` is
  personalised and its window rotates, so use it only if you want a moving sample.
- `minAdsPerHashtag` — a hashtag seen in a single ad is noise. The default of `2` drops it. Hashtags
  below the threshold are removed **before** they are returned and **before** they are billed.
- `maxHashtags` — hard stop, applied after ranking.

### Pricing

Pay per event:

- **`run-started`** — charged **once per run, and only after the board has actually answered with
  parsable data**. A blocked or empty run is not billed at all.
- **`slice-swept`** — charged per `country × period` slice that actually returned parsable data. Each
  slice is a separate browser session against the board, and that is where the real cost of this Actor
  sits. A slice that failed, was blocked or returned nothing is not charged.
- **`hashtag-returned`** — charged per row, **after** the row is written to the dataset.

You are never charged for a hashtag you did not receive, and never twice for the same hashtag in one
run.

**What happens if a run is interrupted.** Apify can migrate a run to another machine mid-flight. This
Actor keeps a durable four-state ledger in its key-value store:

```
intent → write the row → **written** → **charge started** → charge → **billed**
```

On restart:

- a **billed** row is skipped entirely;
- a row with **charge started** but no confirmation is **not charged again** — we cannot know whether
  the payment went through, and undercharging ourselves beats charging you twice. It is counted in
  `chargeUncertainRows` for reconciliation;
- a **written** row that was never charged is charged, without being written again;
- a row with only an **intent** is written again. Its previous write may have failed outright, and a
  duplicate row costs you nothing while billing for a row that never reached the dataset costs you money.

Apify's pay-per-event call takes no idempotency key, so exactly-once is not achievable and this Actor
does not claim it. What it does instead is make the residual uncertainty **visible and small**: the
only unresolved case is a crash inside the charge call itself, and every such row is counted, never
silently re-charged.

### Completeness: when this Actor returns nothing on purpose

The ranking is computed **across every slice**. If one slice fails, or its pagination breaks partway,
the ranking is no longer a ranking of the whole window you asked for — some ads never entered the count.

So by default an incomplete run **returns nothing and charges nothing per row**. It is not a silent
half-answer sold as a whole one. The coverage record tells you exactly which slice broke and why.

Set `allowPartial: true` if you want the incomplete ranking anyway. Then every row carries
`isPartial: true` and **no per-row charge is made at all** — you pay only the run event.

A run that fails outright (`run_wall_timeout`, repeated slice errors) delivers nothing and bills
nothing beyond the run event.

### About the ad links

`sampleAds[].adUrl` points at the ad's own card in TikTok Creative Center, which is where this data
comes from and which we verified opens and shows the same ad. We deliberately do **not** hand you a
`tiktok.com/@/video/...` link: the board exposes an internal video id, not the public post id, and such
a link does not open. `videoId` is given raw for your own matching, without pretending it is a URL.

### Honest limits

- **Coverage of hashtags depends on ad copy.** Measured 10.09.2026 on a live sweep of US/30d and
  GB/30d (140 distinct ads): **63 of 140 ads — 45% — carry hashtags** in their copy; the rest
  contribute to `adsSeen` but to no hashtag. Those 140 ads produced 288 distinct hashtags, of which
  17 cleared the default threshold of 2 ads. The run's `coverage` record reports `adsSeen`,
  `adsWithHashtags` and `hashtagsFound` so you can see exactly this ratio for your own run.
- **Volume comes from slices.** Two slices give a couple of dozen ranked hashtags. If you want a broad
  table, sweep several countries and both the 7- and 30-day windows.
- **Industry and objective names come from a taxonomy snapshot** taken from the board's own filter
  endpoint on 10.09.2026 (258 industries, 7 objectives). If TikTok adds a code we do not know, the raw
  code is still returned and the name field is `null` — the drift is visible in the data, never
  papered over.
- **100 ads per slice** is the source's ceiling, not a setting. Volume comes from sweeping more
  countries and periods.
- **The board's industry/objective URL filters are ignored by the source.** We verified this
  (requesting a specific industry returned the same distribution as no filter), so this Actor does not
  offer filters it cannot honour. Industry and objective are reported per hashtag instead.
- **`for_you` ranking is personalised.** Two runs will not return the same window. Use `ctr` or
  `impression` for anything you intend to compare over time.

### Coverage record

Every run writes a `coverage` record to the key-value store: slices requested vs attempted, per-slice
status and reason, `adsSeen`, `adsWithHashtags`, `hashtagsFound`, `hashtagsQualifying`, `hashtagsSkippedByThreshold`,
`hashtagsSkippedByCap`, `hashtagsDelivered`, `hashtagsBilled`, `runStartCharged`, `completeSlices`,
`incompleteSlices`, `slicesBilled`, `coverageComplete`, `deliveryBlocked`, `truncatedSample`, `ledgerResumed`,
`chargeUncertainRows`, `intentionallyUnbilledRows`, `pushedUnbilledRows`.
Those numbers are enough to reconcile every charge against every row. If part of the run failed, the
record says which part and why — the dataset is never silently short.

Author: OmniCoder (https://t.me/OmniCoder)

# Actor input Schema

## `cdpUrl` (type: `string`):

Optional. Leave EMPTY to use the built-in stealth anti-detect browser — no third-party account or key needed. Provide your own remote browser endpoint (ws:// or wss://, e.g. a Bright Data Scraping Browser) only if you prefer to route through your own. The backend is never switched automatically and is reported in the run's coverage.

## `countryCodes` (type: `array`):

Two-letter country codes to sweep, e.g. US, GB, DE. Each country is a separate slice of the board. Up to 28 (the source's own list).

## `periods` (type: `array`):

Which time windows to sweep: 7, 30 and/or 180. Each period is a separate slice, so \[7,30] doubles the volume.

## `orderBy` (type: `string`):

How the board ranks ads. CTR and Reach are deterministic and repeatable; For You is personalised and its window rotates between runs.

## `maxPagesPerSlice` (type: `integer`):

1-5. The source hard-caps each slice at 100 ads (5 pages x 20), so 5 is the maximum useful value. The prefilled values are a deliberately cheap first run (one country, one period, one page) so you can see the shape of the data before you widen the sweep.

## `webhookUrl` (type: `string`):

Optional. Receives a JSON summary of the run. HTTPS only.

## `minAdsPerHashtag` (type: `integer`):

A hashtag is returned only if it appears in at least this many distinct ads. 1 returns everything including one-off tags; 2 (the default) drops most noise. Hashtags below the threshold are dropped before they are returned and before they are billed.

## `maxHashtags` (type: `integer`):

Hard stop for the run, applied after ranking by ad count. This is the number of rows you actually receive and pay for.

## `allowPartial` (type: `boolean`):

The hashtag ranking is computed across every slice, so if a slice fails or its pagination breaks, the ranking is incomplete. By default such a run returns nothing. Turn this on to receive the incomplete ranking anyway: every row is then flagged isPartial and no per-row charge is made for it.

## Actor input object example

```json
{
  "countryCodes": [
    "US"
  ],
  "periods": [
    30
  ],
  "orderBy": "ctr",
  "maxPagesPerSlice": 1,
  "minAdsPerHashtag": 1,
  "maxHashtags": 50,
  "allowPartial": false
}
```

# Actor output Schema

## `hashtags` (type: `string`):

One row per hashtag: adCount, avgCtr/medianCtr/maxCtr with their denominators, likes, video length, industries and objectives (codes and names), countries, up to 3 sample ads, isPartial and isTruncatedSample flags.

## `coverage` (type: `string`):

Per-slice status and reason, adsSeen, adsWithHashtags, hashtagsFound/Qualifying/SkippedByThreshold/SkippedByCap/Delivered/Billed, runStartCharged, completeSlices, incompleteSlices, deliveryBlocked, chargeUncertainRows, intentionallyUnbilledRows.

# 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 = {
    "countryCodes": [
        "US"
    ],
    "periods": [
        30
    ],
    "maxPagesPerSlice": 1,
    "minAdsPerHashtag": 1,
    "maxHashtags": 50
};

// Run the Actor and wait for it to finish
const run = await client.actor("titan_coder/tiktok-ad-hashtag-miner").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 = {
    "countryCodes": ["US"],
    "periods": [30],
    "maxPagesPerSlice": 1,
    "minAdsPerHashtag": 1,
    "maxHashtags": 50,
}

# Run the Actor and wait for it to finish
run = client.actor("titan_coder/tiktok-ad-hashtag-miner").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 '{
  "countryCodes": [
    "US"
  ],
  "periods": [
    30
  ],
  "maxPagesPerSlice": 1,
  "minAdsPerHashtag": 1,
  "maxHashtags": 50
}' |
apify call titan_coder/tiktok-ad-hashtag-miner --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,titan_coder/tiktok-ad-hashtag-miner"
        }
    }
}
```

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/WOBhc7orGIMS8aVoz/builds/tbac3VeCo0LezhK0F/openapi.json
