DEV Community

Viktor
Viktor

Posted on

How to Scrape Kwai with Python: Public Profiles, Video Feeds, Engagement Stats, and a Creator Growth Tracker (2026)

Kwai is Kuaishou's international short-video app. It's small in the US, but in Brazil and Indonesia it competes with TikTok for creators, brands, and ad budgets. If you work with creators in those markets, you will sooner or later want the numbers behind a Kwai profile: followers, views per video, how often someone posts, and whether an account is actually growing.

Searching for "Kwai API" doesn't get you far. The developer portal Kwai runs is for advertisers. This post shows what kwai.com gives a plain script today, and then builds kwai.py, one Python file that only uses the standard library (urllib, sqlite3, json). It has five commands:

  1. probe reads a profile page the DIY way and checks the feed request the web app makes
  2. profile puts several accounts side by side
  3. posts pulls a creator's recent videos and summarizes views, engagement, hashtags, and posting hours
  4. post looks up one video by URL
  5. track stores snapshots in SQLite and reports follower and view changes between runs

Every command was run on November 11, 2026 on a rented Linux VM (Python 3.13.5), and each output block in this post is pasted unedited from that session.

Read this first. Kwai's Terms of Service forbid this kind of collection. Section 3.2(h) of the terms published at kwai.com/lite prohibits "accessing Kwai Lite and the Services and collecting or processing content made available through Kwai Lite and the Services by using any automatic program, software, engine, web crawler, web page analytics tool, data mining tool or similar tools", and 3.2(k) prohibits "collecting or storing personal data about other Users". (The main terms page at kwai.com/legal loads its text with JavaScript, and I couldn't read it with a plain script. Assume it says the same thing.) So scraping Kwai is against its terms, whether you write the scraper or a third-party service runs it for you. Before you run any of the code below, go through those terms and decide whether the legal risk is acceptable for you. If you only need ad performance numbers, use Kwai's official Marketing API instead. If you go ahead, keep it to a handful of accounts you have a real reason to follow, go slowly, store counts rather than personal details, and don't re-host anyone's videos.

Is there a Kwai API?

There are two official APIs, and neither gives you creator or video data:

Official option What it's for
Kwai for Business Marketing API Advertisers and agencies: campaigns, creatives, reporting on your own ads
Kuaishou Open Platform (open.kuaishou.com) Third-party apps and mini programs for Kuaishou, the Chinese app, behind OAuth and developer registration

There's also the Creator Program, where partner agencies get a dashboard for their own registered creators. That's not something you can call from code.

So if you want public numbers for a profile you don't manage, there's no official route. That gap is what the rest of this post covers, and it's also the part the terms forbid.

Step 1: See what kwai.com sends a script

Unlike a lot of social sites, kwai.com doesn't block a server outright. Its robots.txt only shuts out a few crawlers (Baiduspider, Sogou, PetalBot, Bytespider and two others) and lists a sitemap that only has company and help pages, with no profiles in it. Profile pages like https://www.kwai.com/@KwaiFelicidade returned HTTP 200 with full server-rendered HTML to urllib with a browser User-Agent.

The data sits in a window.__NUXT__ script, but it isn't JSON. Nuxt minifies the page state into a function call: repeated values are moved into parameters, so the state reads ownerCount:{fan:ae,like:2450,photo:ac,...}, and ae and ac are only defined in the argument list at the very end of the script. A regex like fan:(\d+) works on one profile and silently returns nothing on the next. You have to rebuild the parameter table first:

def nuxt_state(page):
    """window.__NUXT__ is a minified function call, not JSON. Rebuild its variables."""
    m = re.search(r"window\.__NUXT__=\(function\(([^)]*)\)\{return (.*)\}\((.*)\)\);</script>", page, re.S)
    if not m:
        return None, {}
    names = m.group(1).split(",")
    values = [js_literal(t) for t in re.findall(r'"(?:[^"\\]|\\.)*"|[^,]+', m.group(3))]
    return m.group(2), dict(zip(names, values))


def js_literal(tok):
    """Arguments are JS, not JSON: void 0 and .5 both show up."""
    tok = tok.strip()
    if tok == "void 0":
        return None
    try:
        return json.loads(tok)
    except ValueError:
        try:
            return float(tok)
        except ValueError:
            return tok


def nuxt_field(body, env, obj, field):
    """Read obj:{...field:VALUE...}; VALUE is a literal or a one/two-letter variable."""
    start = body.find(obj + ":{")
    if start < 0:
        return None
    m = re.search(r"[{,]" + field + r':("(?:[^"\\]|\\.)*"|[\w$.]+)', body[start:start + 3000])
    if not m:
        return None
    tok = m.group(1)
    if tok.startswith('"'):
        return json.loads(tok)
    if re.fullmatch(r"-?\d+(\.\d+)?", tok):
        return float(tok) if "." in tok else int(tok)
    return env.get(tok, tok)
Enter fullscreen mode Exit fullscreen mode

The arguments are JavaScript literals, not JSON, so json.loads fails on them. I ran into void 0 and .5 (no leading zero) in real pages. js_literal handles both.

The web app gets the rest of a profile's feed by POSTing to /rest/o/w/pc/feed/profile from the browser. The probe sends the same request once, so you can see whether it returns anything for that account:

def page_video_ids(handle, page=None):
    """Video IDs linked from the server-rendered profile page (usually 6, newest not guaranteed)."""
    if page is None:
        page = http(f"https://www.kwai.com/@{handle}")[1]
    page = page.replace("\\u002F", "/")
    return list(dict.fromkeys(re.findall(r"/@" + re.escape(handle) + r"/(?:video|photo)/(\d+)", page)))


def cmd_probe(a):
    handle = a.handle.lstrip("@")
    status, page = http(f"https://www.kwai.com/@{handle}")
    title = re.search(r"<title>(.*?)</title>", page)
    print(f"GET /@{handle}: HTTP {status}, {len(page):,} chars, title={html.unescape(title.group(1)) if title else None!r}")
    body, env = nuxt_state(page)
    if body is None:
        print("  no window.__NUXT__ state found")
    else:
        counts = {f: nuxt_field(body, env, "ownerCount", f) for f in ("fan", "follow", "liked", "like", "photo_public")}
        print(f"  __NUXT__ ownerCount: {counts}")
        print(f"  kwai_id={nuxt_field(body, env, 'profile', 'kwai_id')!r} user_id={nuxt_field(body, env, 'profile', 'user_id')}")
        print(f"  video IDs linked in the HTML: {page_video_ids(handle, page)}")
    # The web app loads the rest of the feed with a POST from the browser.
    payload = json.dumps({"userId": handle, "fromPage": "PC_PROFILE", "beforePage": "", "pcursor": "", "count": 20}).encode()
    status, text = http("https://www.kwai.com/rest/o/w/pc/feed/profile", data=payload,
                        headers={"Content-Type": "application/json", "Referer": f"https://www.kwai.com/@{handle}"})
    try:
        feed = json.loads(text)
        print(f"POST feed/profile: HTTP {status}, result={feed.get('result')}, feeds={len(feed.get('feeds') or [])}, cursor={feed.get('cursor')!r}")
    except ValueError:
        print(f"POST feed/profile: HTTP {status}, not JSON ({text[:80]!r})")
Enter fullscreen mode Exit fullscreen mode
$ python3 kwai.py probe KwaiFelicidade
GET /@KwaiFelicidade: HTTP 200, 234,709 chars, title='kwaifelicidade (@KwaiFelicidade) on Kwai'
  __NUXT__ ownerCount: {'fan': 11404, 'follow': 172, 'liked': 71409, 'like': 2450, 'photo_public': 2791}
  kwai_id='KwaiFelicidade' user_id=150001458933228
  video IDs linked in the HTML: ['5214192578797648597', '5240651227047763804', '5259228575499879420', '5188296881925953172', '5222636827562910550', '5222355352404759183']
POST feed/profile: HTTP 200, result=1, feeds=20, cursor='1793820834092'
Enter fullscreen mode Exit fullscreen mode

Some things to notice here:

  • liked is the likes the account received. like is the likes it gave. The profile shows 71.4K likes, and that's liked. If you grab like you get 2,450, the number of videos this account liked. It's an easy mistake, and nothing errors when you make it.
  • The page only links six video IDs. Every active profile I checked had exactly six in its HTML. They aren't necessarily the newest (see Step 5).
  • The feed POST worked here without cookies: 20 videos plus a cursor value to continue from.

Now try a different account:

$ python3 kwai.py probe bigchelo890
GET /@bigchelo890: HTTP 200, 217,433 chars, title='BigWhite👀 (@bigchelo890) on Kwai'
  __NUXT__ ownerCount: {'fan': 90613, 'follow': 5122, 'liked': 878006, 'like': 190126, 'photo_public': 15779}
  kwai_id='bigchelo890' user_id=150000961951053
  video IDs linked in the HTML: ['5229673703355323675', '5233051405253076458', '5188859831765425348', '5242621551339321331', '5233895826588048054', '5225170104861750930']
POST feed/profile: HTTP 200, result=1, feeds=0, cursor='no_more'
Enter fullscreen mode Exit fullscreen mode

The profile shows 15,779 public posts. The feed request still returned result=1 (success) with zero videos and cursor='no_more', and adding cookies from the first GET didn't change that. I tried 11 accounts, and Kwai's own web feed came back empty for 4 of them. That's the main thing to know about Kwai. It's not a block or a captcha, and you get no error. The feed is just empty.

Step 2: A stdlib HTTP client for the API

Reading profile pages yourself works. But rebuilding the minified state is fragile, and the feed is inconsistent. So from here on, the script reads Kwai through ScrapeAtlas*. Its OpenAPI spec lists three Kwai routes: /v1/kwai/profile, /v1/kwai/user/posts, and /v1/kwai/post. You call them with GET and pass your key in the x-api-key header. Each successful call, partial results included, uses 1 credit. Failed calls are free. Using an API doesn't make the terms go away: Kwai's rules still apply to the data, whoever fetches it.

Here's the top of the file and the HTTP helpers:

#!/usr/bin/env python3
"""kwai.py - Kwai public profiles, video feeds, post stats, and a SQLite growth tracker.

Standard library only (Python 3.9+). Kwai's Terms of Service prohibit collecting
content with crawlers or data-mining tools; read them before you run this.

Commands:
  probe   <handle>                    what kwai.com hands a plain script (no key)
  profile <handle>...                 profile counts side by side
  posts   <handle> [--pages N] [--since ISO]   recent videos, engagement, hashtags, posting hours
  post    <post_url>                  one video by its /@handle/photo/<id> URL
  track   <handle>... [--db kwai.db]  save snapshots, report follower and view changes

profile/posts/post/track need SCRAPEATLAS_API_KEY.
"""
import argparse, html, json, os, re, sqlite3, sys, time, urllib.error, urllib.parse, urllib.request
from collections import Counter
from datetime import datetime, timedelta, timezone

API = "https://api.scrapeatlas.com/v1/kwai"
UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36"
HANDLE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$")
HASHTAG = re.compile(r"#(\w+)")


def http(url, data=None, headers=None, timeout=60):
    req = urllib.request.Request(url, data=data, headers={"User-Agent": UA, **(headers or {})})
    try:
        with urllib.request.urlopen(req, timeout=timeout) as r:
            return r.status, r.read().decode("utf-8", "replace")
    except urllib.error.HTTPError as e:
        return e.code, e.read().decode("utf-8", "replace")


def api_get(path, **params):
    key = os.environ.get("SCRAPEATLAS_API_KEY")
    if not key:
        sys.exit("Set SCRAPEATLAS_API_KEY first.")
    qs = urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
    for attempt in range(1, 4):
        status, text = http(f"{API}/{path}?{qs}", headers={"x-api-key": key}, timeout=90)
        body = json.loads(text) if text.startswith("{") else {}
        if body.get("success"):
            return body
        code = (body.get("failure") or {}).get("code", f"HTTP {status}")
        if code in ("rate_limited", "upstream_error", "upstream_challenged") and attempt < 3:
            time.sleep(2 * attempt)  # failed calls cost 0 credits
            continue
        raise RuntimeError(f"{path} {params}: {code}")
Enter fullscreen mode Exit fullscreen mode

Each response has success, status (success, partial, or failed), data, and a coverage block with warnings. Failures come back as failure.code. In this run I saw not_found, invalid_request, and unsupported_page. Only rate limits and upstream errors are worth retrying. The other codes are final.

Step 3: Profiles side by side

def fmt(n):
    if n is None:
        return "-"
    for div, suf in ((1e6, "M"), (1e3, "K")):
        if abs(n) >= div:
            return f"{n / div:.1f}{suf}"
    return str(n)


def get_profile(handle):
    return api_get("profile", handle=handle.lstrip("@"))["data"]


def cmd_profile(a):
    print(f"{'handle':<18} {'followers':>9} {'following':>9} {'likes':>8} {'posts':>7} {'likes/post':>10}  private  name")
    for h in a.handles:
        try:
            p = get_profile(h)
        except RuntimeError as e:
            print(f"{h:<18} failed: {e}")
            continue
        per_post = p["like_count"] / p["public_post_count"] if p["public_post_count"] else 0
        print(f"{p['handle']:<18} {fmt(p['follower_count']):>9} {fmt(p['following_count']):>9} {fmt(p['like_count']):>8} "
              f"{fmt(p['public_post_count']):>7} {per_post:>10.1f}  {str(p['is_private']):<7}  {p['username']}")
Enter fullscreen mode Exit fullscreen mode
$ python3 kwai.py profile KwaiFelicidade bigchelo890 khumaairah SeputarKuliner Qonita_info isnanya sigitnugroho007 loiradokawa
handle             followers following    likes   posts likes/post  private  name
KwaiFelicidade         11.4K       172    71.4K    2.8K       25.6  False    kwaifelicidade
bigchelo890            90.6K      5.1K   878.0K   15.8K       55.6  False    BigWhite👀
khumaairah              1.6M      3.7K    50.1M   51.9K      966.0  False    🥀 KHUMAIRAH 𝐏𝐈𝐈 _👑
SeputarKuliner         24.2K       188   447.3K    7.6K       58.5  False    𝗦𝗲𝗽𝘂𝘁𝗮𝗿 𝗸𝘂𝗹𝗶𝗻𝗲𝗿 💥PS💥
Qonita_info           169.1K      6.1K     7.2M   24.8K      289.7  False    Qonita Info
isnanya               229.6K       408     1.6M    7.5K      207.1  False    ISNA
sigitnugroho007       780.0K       298    21.1M   19.7K     1071.1  False    ⭐movie_production🚀🇮🇩
loiradokawa            51.6K      9.9K   720.4K    4.6K      157.7  False    loira do kwai  oficial
Enter fullscreen mode Exit fullscreen mode

The follower and like counts match the __NUXT__ numbers from Step 1 (11,404 and 71,409 for KwaiFelicidade). The likes/post column is a rough way to compare accounts of different sizes. An account with 780K followers and 1,071 likes per post is a different kind of account from one with 169K followers and 290.

Two things that surprised me:

  • Brand handles you'd guess don't exist. kwai, kwaibrasil, and kwai_oficial all returned not_found. Get handles from real profile URLs instead of guessing.
  • Display names are decorated Unicode. 𝗦𝗲𝗽𝘂𝘁𝗮𝗿 𝗸𝘂𝗹𝗶𝗻𝗲𝗿 uses mathematical bold letters, not ASCII. If you search or dedupe by name, run unicodedata.normalize("NFKC", name) first. Better yet, key everything on the numeric user id.

Step 4: Recent videos, engagement, hashtags, and posting hours

/v1/kwai/user/posts returns one page of a creator's feed, newest first, with up to 50 posts (count goes from 1 to 50). For the next page, pass data.cursor back as cursor. You can also give it since and until as ISO dates or Unix seconds.

def caption_of(post):
    # Captions can arrive HTML-escaped ("#reflex&atilde;o"). Unescape before counting tags.
    return html.unescape(post.get("caption") or "")


def fetch_posts(handle, pages=1, count=50, since=None):
    posts, cursor, warnings, sizes = [], None, set(), []
    for _ in range(pages):
        r = api_get("user/posts", handle=handle.lstrip("@"), count=count, cursor=cursor, since=since)
        warnings.update(r.get("coverage", {}).get("warnings", []))
        sizes.append(len(r["data"]["posts"]))
        for p in r["data"]["posts"]:
            p["id"] = str(p["id"])  # never use photo_id: it is a float and loses digits
            posts.append(p)
        cursor = r["data"].get("cursor")
        if not r["data"].get("has_more") or not cursor:
            break
    seen, unique = set(), []
    for p in posts:
        if p["id"] not in seen:
            seen.add(p["id"])
            unique.append(p)
    return unique, warnings, len(posts) - len(unique), sizes
Enter fullscreen mode Exit fullscreen mode

Two fields in the raw post need care:

  • photo_id is a lossy number. Use id. In the raw JSON, the post 5214192578797648597 has "photo_id": 5214192578797649000. The ID is bigger than 2^53, so it was rounded somewhere upstream on its way through a JavaScript-style double. Python would keep a big integer exactly, but by then the digits are already gone. The string id and the URL have the right value. The same thing happens to album IDs (albumId vs albumIdStr).
  • Captions can arrive HTML-escaped. The same post's caption came through as #reflex&atilde;o. If you count hashtags without html.unescape, #reflexão and #reflex end up as two different tags.

The analysis is simple counting:

def engagement(p):
    views = p.get("view_count") or 0
    acts = (p.get("like_count") or 0) + (p.get("comment_count") or 0) + (p.get("forward_count") or 0)
    return acts / views if views else None


def median(xs):
    xs = sorted(xs)
    if not xs:
        return None
    mid = len(xs) // 2
    return xs[mid] if len(xs) % 2 else (xs[mid - 1] + xs[mid]) / 2


def cmd_posts(a):
    try:
        posts, warnings, dupes, sizes = fetch_posts(a.handle, a.pages, a.count, a.since)
    except RuntimeError as e:
        print(f"feed failed: {e}\nfalling back to the video IDs linked in the profile HTML")
        posts, warnings, dupes, sizes = posts_from_page(a.handle), {"page_fallback"}, 0, []
    if not posts:
        sys.exit("no posts in range")
    first, last = posts[-1]["created_at"][:10], posts[0]["created_at"][:10]
    print(f"{len(posts)} videos from {first} to {last} (page sizes {sizes}, duplicates dropped: {dupes}, warnings: {sorted(warnings) or 'none'})")
    views = [p["view_count"] or 0 for p in posts]
    rates = [r for r in map(engagement, posts) if r is not None]
    print(f"views: median {fmt(median(views))}, max {fmt(max(views))}, total {fmt(sum(views))}")
    print(f"engagement (likes+comments+shares)/views: median {median(rates):.2%}" if rates else "engagement: n/a")
    print("\ntop 5 by views:")
    for p in sorted(posts, key=lambda p: p["view_count"] or 0, reverse=True)[:5]:
        er = engagement(p)
        print(f"  {p['created_at'][:16]}Z  {fmt(p['view_count']):>6} views  {p['like_count']:>5} likes  "
              f"{p['comment_count']:>4} cmts  {p['forward_count']:>4} shares  {er:.1%}  {caption_of(p)[:40]!r}")
    tags = Counter(t.lower() for p in posts for t in HASHTAG.findall(caption_of(p)))
    print("\nhashtags:", ", ".join(f"#{t} x{n}" for t, n in tags.most_common(8)) or "none")
    offset = timedelta(hours=a.utc_offset)
    hours = Counter((datetime.fromisoformat(p["created_at"].replace("Z", "+00:00")) + offset).hour for p in posts)
    print(f"posting hours (UTC{a.utc_offset:+d}):", ", ".join(f"{h:02d}h x{n}" for h, n in sorted(hours.items())))
    if a.json:
        with open(a.json, "w", encoding="utf-8") as f:
            json.dump(posts, f, ensure_ascii=False, indent=1)
        print(f"\nsaved {len(posts)} posts to {a.json}")
Enter fullscreen mode Exit fullscreen mode
$ python3 kwai.py posts KwaiFelicidade --pages 2
93 videos from 2026-10-13 to 2026-11-10 (page sizes [49, 44], duplicates dropped: 0, warnings: ['music_unavailable'])
views: median 759, max 1.6K, total 68.6K
engagement (likes+comments+shares)/views: median 4.78%

top 5 by views:
  2026-10-30T08:45Z    1.6K views     33 likes     1 cmts    14 shares  3.0%  '#motivacional #bomdia'
  2026-10-21T19:07Z    1.2K views     52 likes     1 cmts    23 shares  6.4%  '#JESUS'
  2026-11-01T08:39Z    1.1K views     50 likes     1 cmts    21 shares  6.5%  '#reflexão'
  2026-11-05T08:14Z    1.1K views     49 likes     1 cmts    20 shares  6.5%  '#motivacional'
  2026-10-13T23:56Z    1.1K views     49 likes     1 cmts    20 shares  6.6%  '#ia'

hashtags: #status x26, #bomdia x12, #ia x11, #lula x10, #reflexão x6, #jesus x5, #motivacional x5, #meme x5
posting hours (UTC-3): 04h x1, 05h x25, 06h x34, 07h x2, 15h x3, 16h x13, 17h x7, 20h x7, 21h x1
Enter fullscreen mode Exit fullscreen mode

I asked for 50 per page and got 49, then 44. That's fine. Treat count as an upper limit and keep going until has_more is false. In four weeks this account posted 93 videos with a median of 759 views each. Almost two thirds of them (59) went out between 05:00 and 06:59 Brasília time, which fits an account that posts "bom dia" (good morning) and status videos.

An Indonesian account looks very different. I passed --utc-offset 7 so the hours are in Jakarta time:

$ python3 kwai.py posts sigitnugroho007 --pages 3 --utc-offset 7
150 videos from 2026-11-04 to 2026-11-11 (page sizes [50, 50, 50], duplicates dropped: 0, warnings: ['music_unavailable'])
views: median 3.4K, max 246.2K, total 1.6M
engagement (likes+comments+shares)/views: median 3.25%

top 5 by views:
  2026-11-04T22:43Z  246.2K views   5163 likes    33 cmts   273 shares  2.2%  '#EfekGreenScreen Halloween berdarah #sna'
  2026-11-05T01:02Z  130.1K views   3462 likes    19 cmts   147 shares  2.8%  '#EfekGreenScreen Halloween berdarah #sna'
  2026-11-05T02:06Z   76.8K views   3777 likes    37 cmts    58 shares  5.0%  '#EfekGreenScreen dokter legendaris #snac'
  2026-11-04T22:25Z   75.1K views   1973 likes    22 cmts    90 shares  2.8%  '#EfekGreenScreen Halloween berdarah #sna'
  2026-11-04T22:55Z   43.5K views   1163 likes    13 cmts    61 shares  2.8%  '#EfekGreenScreen Halloween berdarah #sna'

hashtags: #efekgreenscreen x150, #snacktainment x150, #snackfamily x150, #dracinpenguasa x30, #penguasa x30, #dracinibumontir x18, #ibumontir x18, #dracindokterlegendaris x16
posting hours (UTC+7): 04h x1, 05h x13, 06h x5, 07h x1, 08h x15, 09h x18, 10h x2, 11h x5, 12h x6, 13h x11, 14h x6, 15h x4, 16h x2, 17h x2, 18h x12, 19h x11, 20h x13, 21h x21, 22h x2
Enter fullscreen mode Exit fullscreen mode

Three pages, 150 videos, and they only go back one week. That's about 21 uploads a day, all tagged #snacktainment and #snackfamily, which looks like episodes of short dramas being cut up and posted. So with a high-volume account, --pages 3 gets you days, not months. Use --since when you want a fixed window:

$ python3 kwai.py posts KwaiFelicidade --since 2026-11-08T00:00:00Z
9 videos from 2026-11-08 to 2026-11-10 (page sizes [9], duplicates dropped: 0, warnings: ['music_unavailable'])
views: median 554, max 893, total 5.4K
engagement (likes+comments+shares)/views: median 3.93%

top 5 by views:
  2026-11-08T08:30Z     893 views     36 likes     1 cmts    14 shares  5.7%  '#motivacional'
  2026-11-08T09:08Z     785 views     26 likes     2 cmts    10 shares  4.8%  '#resgateanimal #gato'
  2026-11-08T09:58Z     748 views     23 likes     2 cmts    10 shares  4.7%  '#JESUS'
  2026-11-08T09:59Z     647 views     18 likes     0 cmts     7 shares  3.9%  '#louvor'
  2026-11-10T08:57Z     554 views     17 likes     1 cmts     7 shares  4.5%  '#JESUS'

hashtags: #jesus x2, #bomdia x2, #reflexão x1, #louvor x1, #fé x1, #resgateanimal x1, #gato x1, #motivacional x1
posting hours (UTC-3): 05h x3, 06h x6
Enter fullscreen mode Exit fullscreen mode

The engagement rate here is (likes + comments + shares) / views. It's a per-video number, so it's comparable across accounts of different sizes. Don't divide by followers. On Kwai, most views come from the recommendation feed, so followers say little about reach.

Step 5: When the feed comes back empty

Here's what happened with user/posts on the 11 accounts I tried:

Accounts user/posts Kwai's own web feed POST
5 (KwaiFelicidade, sigitnugroho007, isnanya, and two others) worked worked
4 (bigchelo890, loiradokawa, Qonita_info, one other) unsupported_page empty, cursor: "no_more"
2 (khumaairah, SeputarKuliner) unsupported_page 22 and 23 items for a request of 20

Retrying didn't help. It failed the same way every time, and with count=5, 20, or 50. For the four accounts where Kwai's own feed is empty, there's nothing left to get, so a failure is the honest answer. For the last two, the web app gets videos and the API doesn't. That's a real gap. Both returned more items than the 20 the web app asked for, while every account that worked returned exactly 20. My guess is that extra items, such as pinned videos, break a consistency check, but that's only a guess. If you depend on one of those accounts, check it with probe first.

Even when the feed is gone, the profile page still links six video IDs, and /v1/kwai/post works on any of them. One catch: the site links them as /@handle/video/<id>, but the endpoint only accepts /@handle/photo/<id>. The ID is the same, only the path changes:

def posts_from_page(handle):
    handle = handle.lstrip("@")
    posts = []
    for vid in page_video_ids(handle):
        try:
            p = api_get("post", url=f"https://www.kwai.com/@{handle}/photo/{vid}")["data"]
        except RuntimeError as e:
            print(f"  {vid}: {e}")
            continue
        p["id"] = str(p["id"])
        posts.append(p)
    return sorted(posts, key=lambda p: p["created_at"], reverse=True)


def photo_url(url):
    # The site links /video/<id>; the API only accepts the /photo/<id> form of the same ID.
    return re.sub(r"/video/(\d+)$", r"/photo/\1", url.split("?")[0])
Enter fullscreen mode Exit fullscreen mode
$ python3 kwai.py posts bigchelo890
feed failed: user/posts {'handle': 'bigchelo890', 'count': 50, 'cursor': None, 'since': None}: unsupported_page
falling back to the video IDs linked in the profile HTML
6 videos from 2026-09-03 to 2026-09-12 (page sizes [], duplicates dropped: 0, warnings: ['page_fallback'])
views: median 847.5, max 1.1K, total 5.1K
engagement (likes+comments+shares)/views: median 4.54%

top 5 by views:
  2026-09-03T21:16Z    1.1K views     43 likes     1 cmts    17 shares  5.3%  '#viraliza #fy #videoIA'
  2026-09-03T20:59Z    1.1K views     49 likes     1 cmts    19 shares  6.4%  '#viraliza #fy #videoIA'
  2026-09-03T21:06Z     851 views     27 likes     1 cmts     4 shares  3.8%  '#viraliza #fy #videoIA'
  2026-09-03T20:52Z     844 views     34 likes     0 cmts    11 shares  5.3%  '#viraliza #fy #videoIA'
  2026-09-03T20:45Z     694 views     17 likes     0 cmts     8 shares  3.6%  '#viraliza #fy #videoIA'

hashtags: #viraliza x5, #fy x5, #videoia x5, #umdiaperfeito x1, #granasextras x1, #dinheiroonline x1, #resgateemreais x1
posting hours (UTC-3): 06h x1, 17h x3, 18h x2
Enter fullscreen mode Exit fullscreen mode

It works, but look at the dates: September 3 to 12. This account posts every day, so whatever decides which six videos the page links, it isn't recency. Use the fallback to sample an account. Don't treat it as the account's recent activity.

Step 6: One video by URL

def cmd_post(a):
    r = api_get("post", url=photo_url(a.url))
    p = r["data"]
    print(f"{p['url']}\n  status={r['status']} warnings={r.get('coverage', {}).get('warnings')}")
    print(f"  by @{p['author']['handle']} at {p['created_at']} (created_at_text {p['created_at_text']!r})")
    print(f"  views={p['view_count']} likes={p['like_count']} comments={p['comment_count']} shares={p['forward_count']}")
    print(f"  caption={caption_of(p)!r} tags={p.get('tags')} music={(p.get('music') or {}).get('name')!r}")
Enter fullscreen mode Exit fullscreen mode
$ python3 kwai.py post https://www.kwai.com/@sigitnugroho007/video/5195052281313395671
https://www.kwai.com/@sigitnugroho007/photo/5195052281313395671
  status=partial warnings=['music_unavailable', 'tags_unavailable']
  by @sigitnugroho007 at 2026-11-11T02:34:52.787Z (created_at_text '2026-11-11 10:34:52')
  views=1001 likes=50 comments=6 shares=1
  caption='#EfekGreenScreen berpura-pura #snacktainment #snackfamily #dracinberpurapura #berpurapura part 7' tags=None music=None
Enter fullscreen mode Exit fullscreen mode

Notes:

  • created_at_text is UTC+8, not your time and not UTC. 02:34:52Z came back as 10:34:52, which is Singapore/Beijing time, the zone Kwai's servers use. Only use created_at.
  • Single-post lookups were partial every time, with music_unavailable and tags_unavailable. The counts were all there. Hashtags only exist in the caption text, so parse them from there, like cmd_posts does.
  • This video was less than two hours old when I looked it up and already had 1,001 views. New videos move fast at first, so one snapshot tells you little. That's what the tracker is for.

Step 7: A small growth tracker in SQLite

Two tables: one row per profile per run, and one row per video per run. Each run compares against the previous row for the same key.

SCHEMA = """
CREATE TABLE IF NOT EXISTS profile_snap (handle TEXT, taken_at TEXT, followers INT, likes INT, posts INT);
CREATE TABLE IF NOT EXISTS post_snap (post_id TEXT, handle TEXT, taken_at TEXT, created_at TEXT,
                                      views INT, likes INT, comments INT, shares INT, caption TEXT);
CREATE INDEX IF NOT EXISTS post_snap_id ON post_snap(post_id, taken_at);
"""


def cmd_track(a):
    db = sqlite3.connect(a.db)
    db.executescript(SCHEMA)
    now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
    for h in a.handles:
        h = h.lstrip("@")
        try:
            p = get_profile(h)
        except RuntimeError as e:
            print(f"@{h}: profile failed ({e})")
            continue
        prev = db.execute("SELECT taken_at, followers, likes, posts FROM profile_snap WHERE handle=? ORDER BY taken_at DESC LIMIT 1", (h,)).fetchone()
        db.execute("INSERT INTO profile_snap VALUES (?,?,?,?,?)", (h, now, p["follower_count"], p["like_count"], p["public_post_count"]))
        line = f"@{h}: {p['follower_count']:,} followers, {p['public_post_count']:,} posts"
        if prev:
            line += (f" | since {prev[0]}: followers {p['follower_count'] - prev[1]:+,}, "
                     f"likes {p['like_count'] - prev[2]:+,}, posts {p['public_post_count'] - prev[3]:+,}")
        print(line)
        try:
            posts = fetch_posts(h, pages=1, count=a.count)[0]
        except RuntimeError as e:
            print(f"  feed failed ({e.args[0].split(': ')[-1]}); using the video IDs linked in the profile HTML")
            posts = posts_from_page(h)
        new, grew = 0, []
        for q in posts:
            last = db.execute("SELECT views, likes FROM post_snap WHERE post_id=? ORDER BY taken_at DESC LIMIT 1", (q["id"],)).fetchone()
            db.execute("INSERT INTO post_snap VALUES (?,?,?,?,?,?,?,?,?)",
                       (q["id"], h, now, q["created_at"], q["view_count"], q["like_count"], q["comment_count"], q["forward_count"], caption_of(q)[:200]))
            if last is None:
                new += 1
            elif (q["view_count"] or 0) > (last[0] or 0):
                grew.append((q["view_count"] - last[0], q))
        print(f"  {len(posts)} recent videos: {new} new since last run, {len(grew)} gained views")
        for d, q in sorted(grew, key=lambda x: x[0], reverse=True)[:3]:
            print(f"    +{d:,} views  {q['created_at'][:10]}  {q['url']}")
    db.commit()
Enter fullscreen mode Exit fullscreen mode

First run:

$ python3 kwai.py track KwaiFelicidade sigitnugroho007 bigchelo890
@KwaiFelicidade: 11,404 followers, 2,791 posts
  20 recent videos: 20 new since last run, 0 gained views
@sigitnugroho007: 779,954 followers, 19,693 posts
  20 recent videos: 20 new since last run, 0 gained views
@bigchelo890: 90,613 followers, 15,779 posts
  feed failed (unsupported_page); using the video IDs linked in the profile HTML
  6 recent videos: 6 new since last run, 0 gained views
Enter fullscreen mode Exit fullscreen mode

Every video is "new" on the first run, because there's nothing to compare it against yet. Here's the second run, 24 minutes later:

$ python3 kwai.py track KwaiFelicidade sigitnugroho007 bigchelo890
@KwaiFelicidade: 11,404 followers, 2,791 posts | since 2026-11-11T04:15:13Z: followers +0, likes +0, posts +0
  20 recent videos: 0 new since last run, 0 gained views
@sigitnugroho007: 779,965 followers, 19,693 posts | since 2026-11-11T04:15:13Z: followers +11, likes +378, posts +0
  20 recent videos: 0 new since last run, 20 gained views
    +597 views  2026-11-11  https://www.kwai.com/@sigitnugroho007/photo/5254724977915864696
    +594 views  2026-11-11  https://www.kwai.com/@sigitnugroho007/photo/5195052281313395671
    +508 views  2026-11-11  https://www.kwai.com/@sigitnugroho007/photo/5259510052261663118
@bigchelo890: 90,613 followers, 15,779 posts | since 2026-11-11T04:15:13Z: followers +0, likes +0, posts +0
  feed failed (unsupported_page); using the video IDs linked in the profile HTML
  6 recent videos: 0 new since last run, 0 gained views
Enter fullscreen mode Exit fullscreen mode

Over those 24 minutes, sigitnugroho007 gained 11 followers and 378 likes, and all 20 of its newest videos picked up views. The top three each gained 500 to 600, and one of them is the video from Step 6. KwaiFelicidade didn't change at all, down to the last view. Either nobody watched in those 24 minutes, or the counts are cached for a while. Don't run the tracker more often than every hour or so, because short gaps mostly show you noise. bigchelo890 went through the fallback again: its profile row is stored, and the six videos are the same old ones as before, so they show no change.

Run it from cron a few times a day for the accounts you care about. Each account costs 2 credits per run (profile + one feed page), or 7 when it falls back to the six page-linked videos.

The CLI

def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    sub = ap.add_subparsers(dest="cmd", required=True)
    s = sub.add_parser("probe"); s.add_argument("handle"); s.set_defaults(fn=cmd_probe)
    s = sub.add_parser("profile"); s.add_argument("handles", nargs="+"); s.set_defaults(fn=cmd_profile)
    s = sub.add_parser("posts"); s.add_argument("handle"); s.add_argument("--pages", type=int, default=1)
    s.add_argument("--count", type=int, default=50); s.add_argument("--since")
    s.add_argument("--utc-offset", type=int, default=-3, help="hour offset for the posting-hours chart (Brazil = -3)")
    s.add_argument("--json"); s.set_defaults(fn=cmd_posts)
    s = sub.add_parser("post"); s.add_argument("url"); s.set_defaults(fn=cmd_post)
    s = sub.add_parser("track"); s.add_argument("handles", nargs="+"); s.add_argument("--db", default="kwai.db")
    s.add_argument("--count", type=int, default=20); s.set_defaults(fn=cmd_track)
    a = ap.parse_args()
    for h in getattr(a, "handles", []) + ([a.handle] if hasattr(a, "handle") else []):
        if not HANDLE.match(h.lstrip("@")):
            sys.exit(f"not a Kwai handle: {h!r}")
    a.fn(a)


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Gotchas I hit, collected

  • The terms forbid it. Automated collection and storing data about other users are both prohibited. Keep it small and keep it to counts.
  • __NUXT__ is a function call, not JSON. Values get moved into parameters, and the arguments use JavaScript literals like void 0 and .5.
  • like ≠ liked. liked (API: like_count) is likes received. like is likes given.
  • Empty feeds are normal. Kwai's own feed returned nothing for 4 of 11 accounts, and user/posts failed for 6. Check with probe, and use the six page-linked videos if you need something.
  • Page-linked videos aren't the newest. For one account they were two months old.
  • /video/ vs /photo/. Same ID, but the API only accepts /photo/.
  • photo_id lost its last digits. Key everything on the string id.
  • Captions can be HTML-escaped. Unescape them before you count hashtags.
  • created_at_text is UTC+8. Use created_at.
  • count is a maximum. Pages of 49 and 44 are normal. Follow has_more.
  • High-volume accounts burn through pages. 150 videos covered one week for one account. Use --since.
  • Guessed handles fail. kwai, kwaibrasil, kwai_oficial → not_found.

Wrapping up

  • There's no public Kwai data API. The official one is for advertisers.
  • kwai.com serves profile HTML to a script, but the state is minified and the feed request is often empty.
  • Profile counts are reliable. Feeds work for some accounts and not for others, and you get no error message when they don't.
  • Per-video engagement and posting hours say more about a creator than follower counts do.
  • A two-table SQLite tracker is enough to see who's growing and which videos are getting views.

Are you tracking creators on Kwai yet, or only on TikTok and Instagram? I'd like to hear how you handle the empty feeds.


*I build it.

Top comments (0)