TikTok Scraper to CSV turns one keyword and one hashtag into a single CSV of the public TikToks Desearch returned, plus a short summary: median views, the top 10 by engagement rate, and how many videos were posted in each UTC hour.
How this differs
Export Twitter search results to CSV writes an X search to a sheet. TikTok trending hashtags by region counts tags on the TikTok trending feed. This script searches TikTok by a keyword and a hashtag, merges the two lists, and writes one CSV. It does not read the trending feed.
The problem
An analyst who wants views, likes, comments, shares, captions and post times for one topic should not have to copy videos by hand. A keyword search and a hashtag feed are different sets. Putting them in one sheet, without duplicate video ids, is the useful file.
What the app does
- Calls
POST /desearch/tiktok/searchwith{"query", "count"}andGET /desearch/tiktok/hashtag/{tag}. - Asks for a second page by sending the running offset as
cursorwhen the list body has no cursor field. - Dedupes by video id across pages and sources.
sourceissearch,hashtag, orsearch+hashtag. - Labels creators "Creator 1", "Creator 2" and so on. Account names and links inside captions are masked.
- Sets
engagement_rateto (likes + comments + shares) / views. - Writes the CSV and a summary.
--from-rawrebuilds the CSV from saved JSON with no new calls.
How it works
- Keyword.
POST /desearch/tiktok/searchonce per page. - Hashtag.
GET /desearch/tiktok/hashtag/{tag}?count=...once per page. - Flatten. One row per video id. The hashtag feed is not ordered by date, so filter
create_time_utcif you want recent videos only. - Summarise median views, the top 10 by engagement rate, and the UTC posting-hour spread.
The code
One row of the CSV:
def row(p, source, creators):
st = p.get("stats") or {}
a = (p.get("author") or {}).get("id") or (p.get("author") or {}).get("username") or "?"
creators.setdefault(a, f"Creator {len(creators) + 1}")
t = to_utc(p.get("createTime"))
views = st.get("playCount")
eng = sum(x or 0 for x in (st.get("digCount"), st.get("commentCount"), st.get("shareCount")))
vid = str(p.get("id") or "")
return {"video_id": vid, "link": f"https://www.tiktok.com/video/{vid}" if vid else "",
"source": source, "creator": creators[a],
"caption": clean(p.get("text")),
"create_time_utc": t.strftime("%Y-%m-%d %H:%M") if t else "", "posting_hour_utc": t.hour if t else "",
"views": views, "likes": st.get("digCount"), "comments": st.get("commentCount"),
"shares": st.get("shareCount"), "saves": st.get("collectCount"),
"engagement_rate": round(eng / views, 4) if views else "",
"duration_s": round(p["videoDuration"] / 1000, 1) if isinstance(p.get("videoDuration"), int) else ""}
The live calls:
def run_live(a):
import requests
key = os.environ.get("DESEARCH_API_KEY") or open(os.path.expanduser(os.environ["DESEARCH_KEY_FILE"])).read().strip()
s = requests.Session(); s.headers["Authorization"] = key
raw = {"keyword": a.keyword, "hashtag": a.hashtag, "count": a.count, "started": now(), "calls": []}
cursor = None
for i in range(a.pages):
body = {"query": a.keyword, "count": a.count}
if cursor: body["cursor"] = cursor
rec = call(s, "POST", "/desearch/tiktok/search", f"search p{i+1}", body=body); raw["calls"].append(rec)
cursor = next_cursor(rec, a.count * (i + 1))
if not posts(rec): break
cursor = None
for i in range(a.pages):
params = {"count": a.count}
if cursor: params["cursor"] = cursor
rec = call(s, "GET", f"/desearch/tiktok/hashtag/{a.hashtag}", f"hashtag p{i+1}", params=params); raw["calls"].append(rec)
cursor = next_cursor(rec, a.count * (i + 1))
if not posts(rec): break
if a.empty_check:
rec = call(s, "GET", f"/desearch/tiktok/hashtag/{a.empty_check}", "empty-result check", params={"count": 5})
rec["probe"] = True; raw["calls"].append(rec)
os.makedirs("evidence", exist_ok=True)
json.dump(raw, open(f"evidence/raw-{a.label}.json", "w"), indent=1, default=str)
return raw
The key comes from the environment and is sent as the Authorization header. Captions mask account names and links before they are written.
Endpoints
POST /desearch/tiktok/searchGET /desearch/tiktok/hashtag/{tag}
Example output
The 2026-10-16 sample used the keyword "skincare routine" and the hashtag skincare, 2 pages of 15 each. 60 rows came back and 59 were unique videos. In that run the keyword set and the hashtag set did not overlap. Every row had views, likes, comments, shares and saves filled.
Run it yourself
The public examples folder linked above includes tiktok_to_csv.py and a README. Install its requirements, set DESEARCH_API_KEY from console.desearch.ai, and use the run command in that README.
What it costs
Each response records X-Desearch-Cost-Usd. The sample write-up does not add those headers into a single total. Start with a small count and read the header on the first response before you raise --pages. See pricing.
Ideas to extend it
- Filter the CSV to videos posted in the last 30 days.
- Add a column for the page number that returned the video.
- Schedule a weekly run and append new video ids only.
