You have a product name, a campaign hashtag or a competitor, and someone asks: "Where are people actually talking about this, TikTok, YouTube or X?" Answering that usually means three different APIs, three auth schemes and three response shapes. In this tutorial we'll write one short Python script that searches all three for the same keyword, normalizes the posts into one table, and prints a side-by-side comparison: how many posts, how recent, how much reach, and the top posts per platform.
We'll use Qovia, a paid public-data API that exposes TikTok, YouTube and X search (plus about 20 other platforms) behind one key and one JSON envelope. Full disclosure: I work on Qovia. Everything below uses only the documented endpoints from the API reference.
What you need
- Python 3.9+ and
requests(pip install requests pandas) - A Qovia API key from the console. New accounts get 50 trial calls, which is plenty for this tutorial. Each search call below costs $0.001 after that, and empty results are not charged.
export QOVIA_KEY="sk-qovia-..."
The three endpoints
| Platform | Endpoint | Useful params |
|---|---|---|
| TikTok | GET /v1/raw/tiktok/search |
keyword, sort (general / most_liked), publish_time (all, day, week, month, quarter, half_year), region (e.g. US), count (max 30), integer cursor
|
| YouTube | GET /v1/raw/youtube/search |
keyword, upload_date (hour, today, week, month, year; default month), string cursor
|
| X | GET /v1/raw/x/search |
keyword (X search syntax works, e.g. #hashtag), type (top / media), string cursor
|
All three return the same record shape inside data.items: id, platform, text, author (with nickname, followers), metrics, and extra (with published_at in UTC and url). Pagination is the same everywhere: pass back data.cursor, stop when data.has_more is false.
The metrics differ per platform, and it's worth knowing that up front:
-
TikTok search records carry
plays,likes,comments,shares,saves. -
YouTube search records carry
plays(view count) only. Likes and comments are on the video detail endpoint,raw.youtube.video. -
X search records carry
views,likes,comments,shares(reposts),quotes,saves.
So "reach" (plays or views) is comparable across all three, and "engagement" only across TikTok and X unless you enrich YouTube.
A tiny client
Every Qovia response is either {"request_id", "data", "meta"} on success or {"error": {"code", "message", ...}} on failure. Rate limits are 60 requests per minute on a new account, and a 429 comes with Retry-After, so the client honours that.
import os, time, requests
BASE = "https://api.qovia.ai"
SESSION = requests.Session()
SESSION.headers["Authorization"] = f"Bearer {os.environ['QOVIA_KEY']}"
def call(path, **params):
for attempt in range(4):
r = SESSION.get(BASE + path, params=params, timeout=60)
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", "5")))
continue
body = r.json()
if "error" in body:
code = body["error"]["code"]
if code in ("upstream_busy", "upstream_timeout") and attempt < 3:
time.sleep(2 * (attempt + 1))
continue
raise RuntimeError(f"{path}: {code}: {body['error']['message']}")
return body["data"] or {}, body["meta"]
raise RuntimeError(f"{path}: gave up after retries")
Two details from the docs worth respecting: unknown query parameters are rejected with 400 invalid_params (so typos fail loudly instead of being ignored), and an empty result is still HTTP 200 with meta.empty: true, which is why we fall back to {}.
Paging through each platform
def search(path, pages=3, **params):
items, cursor = [], None
for _ in range(pages):
p = dict(params)
if cursor is not None:
p["cursor"] = cursor
data, meta = call(path, **p)
items += data.get("items", [])
if not data.get("has_more"):
break
cursor = data.get("cursor")
# TikTok notes adjacent pages may repeat a few posts: dedupe by id
return list({it["id"]: it for it in items}.values())
def collect(keyword, pages=3):
return {
"tiktok": search("/v1/raw/tiktok/search", pages, keyword=keyword,
publish_time="month", region="US", count=30),
"youtube": search("/v1/raw/youtube/search", pages, keyword=keyword,
upload_date="month"),
"x": search("/v1/raw/x/search", pages, keyword=keyword, type="top"),
}
Three pages per platform is nine calls, under one cent once the trial is used up.
One honest caveat about the time windows: TikTok and YouTube let you ask for "the last month", while X search returns X's "top" order with no date filter. We'll filter X by published_at ourselves after the fact so the comparison is fair.
Normalizing into one table
import pandas as pd
def to_rows(results):
rows = []
for platform, items in results.items():
for it in items:
m, ex, au = it.get("metrics") or {}, it.get("extra") or {}, it.get("author") or {}
reach = m.get("plays") if platform != "x" else m.get("views")
engagement = None
if platform in ("tiktok", "x"):
engagement = sum((m.get(k) or 0) for k in ("likes", "comments", "shares", "saves"))
rows.append({
"platform": platform,
"id": it["id"],
"published_at": pd.to_datetime(ex.get("published_at"), utc=True),
"author": au.get("nickname"),
"author_followers": au.get("followers"),
"reach": reach,
"engagement": engagement,
"text": (ex.get("title") or it.get("text") or "").replace("\n", " ")[:90],
"url": ex.get("url"),
})
return pd.DataFrame(rows)
YouTube records put the video title in extra.title and title-plus-snippet in text, so we prefer the title. Note that YouTube's author.followers is null in search results; the channel endpoint has subscriber counts if you need them.
The comparison
def compare(keyword, days=30):
df = to_rows(collect(keyword))
cutoff = pd.Timestamp.now(tz="UTC") - pd.Timedelta(days=days)
df = df[df["published_at"] >= cutoff]
summary = df.groupby("platform").agg(
posts=("id", "count"),
median_reach=("reach", "median"),
total_reach=("reach", "sum"),
median_engagement=("engagement", "median"),
newest=("published_at", "max"),
)
print(f"\n=== '{keyword}' over the last {days} days ===")
print(summary.to_string())
for platform, group in df.groupby("platform"):
print(f"\nTop 3 on {platform} by reach:")
for _, r in group.nlargest(3, "reach").iterrows():
print(f" {int(r.reach or 0):>10,} {r.author}: {r.text}")
print(f" {r.url}")
return df
if __name__ == "__main__":
import sys
df = compare(sys.argv[1] if len(sys.argv) > 1 else "cursor ai")
df.to_csv("comparison.csv", index=False)
Run it:
python compare.py "your product name"
You get a summary table with one row per platform plus the three biggest posts on each, and a comparison.csv you can open in a spreadsheet or feed into a notebook.
Reading the result
A few patterns show up quickly when you run this on real keywords:
- Lots of TikTok posts, low median plays: the topic is being used as a hashtag by small creators. Good for UGC, weak as a signal of reach.
- Few YouTube videos, high median plays: long-form explainers and reviews own the topic. That's where search traffic goes.
-
X dominated by a few accounts: check
author_followers. If the top posts are all from accounts with huge followings, the "conversation" is really a couple of announcements and their replies.
Because the three samples are each platform's own ranking (TikTok relevance, YouTube relevance, X "top"), treat counts as a sample, not a census. The useful signal is the relative shape: where reach concentrates, how fresh the newest posts are, and who is driving it.
Going further
-
Engagement for YouTube: call
GET /v1/raw/youtube/video?video_id=...withextra.source_idfor the top videos to get likes and comment counts. -
What people actually say: pull comments with
raw.tiktok.comments,raw.youtube.commentsandraw.x.commentsfor the top posts and run the same sentiment or keyword pass across all three. - Run it daily: append each run's summary with a date column and you have a cross-platform trend line for free.
-
Ask an assistant instead: the same endpoints are available as an MCP server at
https://api.qovia.ai/mcp(OAuth or the same API key as a Bearer token), so Claude, Cursor or VS Code can run this comparison conversationally. See the MCP docs.
The full parameter list for every endpoint is in the API reference, and there's a single-file version at qovia.ai/llms-full.txt if you want to drop it into a model's context.
This article was written with AI assistance and reviewed against Qovia's documentation. I work on Qovia.
Top comments (0)